feat: add wallet profile database schema and repositories (#11)
- Add SQLAlchemy models for wallet profiles, funding transfers, and relationships - Add WalletRepository, FundingRepository, RelationshipRepository with async support - Add DatabaseManager for connection and session management - Add Alembic migration for initial schema - Include comprehensive test suite with 21 tests using in-memory SQLite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
656bd6469c
commit
0003d228e5
+79
@@ -0,0 +1,79 @@
|
|||||||
|
# Alembic Configuration File
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# Path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# Template used to generate migration files
|
||||||
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s
|
||||||
|
|
||||||
|
# Prepend timestamp to migration file names
|
||||||
|
prepend_date = True
|
||||||
|
|
||||||
|
# Timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename. Use UTC for consistency.
|
||||||
|
timezone = UTC
|
||||||
|
|
||||||
|
# Max length of characters to apply to the "slug" field
|
||||||
|
truncate_slug_length = 40
|
||||||
|
|
||||||
|
# Set to 'true' to run the environment during the 'revision' command
|
||||||
|
revision_environment = false
|
||||||
|
|
||||||
|
# Set to 'true' to allow .pyc and .pyo files without a source .py file
|
||||||
|
sourceless = false
|
||||||
|
|
||||||
|
# Version location specification
|
||||||
|
version_locations = %(here)s/alembic/versions
|
||||||
|
|
||||||
|
# Version path separator
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
# Database URL - override with SQLALCHEMY_DATABASE_URL environment variable
|
||||||
|
sqlalchemy.url = postgresql://localhost/polymarket_tracker
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# Post write hooks define scripts to run after generating new revision files
|
||||||
|
|
||||||
|
# Format using "black" - only if available
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -q
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Alembic migration environment configuration."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from polymarket_insider_tracker.storage.models import Base
|
||||||
|
|
||||||
|
# this is the Alembic Config object
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Target metadata for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Get database URL from environment variable or config
|
||||||
|
database_url = os.environ.get("SQLALCHEMY_DATABASE_URL")
|
||||||
|
if database_url:
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL and not an Engine,
|
||||||
|
though an Engine is acceptable here as well. By skipping the Engine
|
||||||
|
creation we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine and associate a
|
||||||
|
connection with the context.
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Initial schema for wallet profiles and funding transfers.
|
||||||
|
|
||||||
|
Revision ID: 001_initial
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-01-04 00:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "001_initial"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Wallet profiles table
|
||||||
|
op.create_table(
|
||||||
|
"wallet_profiles",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("address", sa.String(42), nullable=False),
|
||||||
|
sa.Column("nonce", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("is_fresh", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("matic_balance", sa.Numeric(30, 0), nullable=True),
|
||||||
|
sa.Column("usdc_balance", sa.Numeric(20, 6), nullable=True),
|
||||||
|
sa.Column("analyzed_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("address"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_wallet_profiles_address", "wallet_profiles", ["address"])
|
||||||
|
|
||||||
|
# Funding transfers table
|
||||||
|
op.create_table(
|
||||||
|
"funding_transfers",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("from_address", sa.String(42), nullable=False),
|
||||||
|
sa.Column("to_address", sa.String(42), nullable=False),
|
||||||
|
sa.Column("amount", sa.Numeric(30, 6), nullable=False),
|
||||||
|
sa.Column("token", sa.String(10), nullable=False),
|
||||||
|
sa.Column("tx_hash", sa.String(66), nullable=False),
|
||||||
|
sa.Column("block_number", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("tx_hash"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_funding_transfers_to", "funding_transfers", ["to_address"])
|
||||||
|
op.create_index("idx_funding_transfers_from", "funding_transfers", ["from_address"])
|
||||||
|
op.create_index("idx_funding_transfers_block", "funding_transfers", ["block_number"])
|
||||||
|
|
||||||
|
# Wallet relationships table
|
||||||
|
op.create_table(
|
||||||
|
"wallet_relationships",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("wallet_a", sa.String(42), nullable=False),
|
||||||
|
sa.Column("wallet_b", sa.String(42), nullable=False),
|
||||||
|
sa.Column("relationship_type", sa.String(20), nullable=False),
|
||||||
|
sa.Column("confidence", sa.Numeric(3, 2), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"wallet_a", "wallet_b", "relationship_type", name="uq_wallet_relationship"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("idx_wallet_relationships_a", "wallet_relationships", ["wallet_a"])
|
||||||
|
op.create_index("idx_wallet_relationships_b", "wallet_relationships", ["wallet_b"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_wallet_relationships_b", table_name="wallet_relationships")
|
||||||
|
op.drop_index("idx_wallet_relationships_a", table_name="wallet_relationships")
|
||||||
|
op.drop_table("wallet_relationships")
|
||||||
|
|
||||||
|
op.drop_index("idx_funding_transfers_block", table_name="funding_transfers")
|
||||||
|
op.drop_index("idx_funding_transfers_from", table_name="funding_transfers")
|
||||||
|
op.drop_index("idx_funding_transfers_to", table_name="funding_transfers")
|
||||||
|
op.drop_table("funding_transfers")
|
||||||
|
|
||||||
|
op.drop_index("idx_wallet_profiles_address", table_name="wallet_profiles")
|
||||||
|
op.drop_table("wallet_profiles")
|
||||||
@@ -1 +1,45 @@
|
|||||||
"""Storage layer - Database schemas and repositories."""
|
"""Storage layer - Database schemas and repositories."""
|
||||||
|
|
||||||
|
from polymarket_insider_tracker.storage.database import (
|
||||||
|
DatabaseManager,
|
||||||
|
create_async_db_engine,
|
||||||
|
create_async_session_factory,
|
||||||
|
create_sync_engine,
|
||||||
|
create_sync_session_factory,
|
||||||
|
init_async_db,
|
||||||
|
init_db,
|
||||||
|
)
|
||||||
|
from polymarket_insider_tracker.storage.models import (
|
||||||
|
Base,
|
||||||
|
FundingTransferModel,
|
||||||
|
WalletProfileModel,
|
||||||
|
WalletRelationshipModel,
|
||||||
|
)
|
||||||
|
from polymarket_insider_tracker.storage.repos import (
|
||||||
|
FundingRepository,
|
||||||
|
FundingTransferDTO,
|
||||||
|
RelationshipRepository,
|
||||||
|
WalletProfileDTO,
|
||||||
|
WalletRelationshipDTO,
|
||||||
|
WalletRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"DatabaseManager",
|
||||||
|
"FundingRepository",
|
||||||
|
"FundingTransferDTO",
|
||||||
|
"FundingTransferModel",
|
||||||
|
"RelationshipRepository",
|
||||||
|
"WalletProfileDTO",
|
||||||
|
"WalletProfileModel",
|
||||||
|
"WalletRelationshipDTO",
|
||||||
|
"WalletRelationshipModel",
|
||||||
|
"WalletRepository",
|
||||||
|
"create_async_db_engine",
|
||||||
|
"create_async_session_factory",
|
||||||
|
"create_sync_engine",
|
||||||
|
"create_sync_session_factory",
|
||||||
|
"init_async_db",
|
||||||
|
"init_db",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Database connection and session management.
|
||||||
|
|
||||||
|
This module provides the database engine, session factory, and
|
||||||
|
async session support for the storage layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from polymarket_insider_tracker.storage.models import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy import Engine
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_sync_engine(database_url: str, **kwargs: Any) -> Engine:
|
||||||
|
"""Create a synchronous SQLAlchemy engine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_url: Database connection URL (e.g., postgresql://...).
|
||||||
|
**kwargs: Additional engine options.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SQLAlchemy Engine instance.
|
||||||
|
"""
|
||||||
|
return create_engine(database_url, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def create_async_db_engine(database_url: str, **kwargs: Any) -> AsyncEngine:
|
||||||
|
"""Create an asynchronous SQLAlchemy engine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_url: Database connection URL (e.g., postgresql+asyncpg://...).
|
||||||
|
**kwargs: Additional engine options.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SQLAlchemy AsyncEngine instance.
|
||||||
|
"""
|
||||||
|
return create_async_engine(database_url, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def create_sync_session_factory(engine: Engine) -> sessionmaker[Session]:
|
||||||
|
"""Create a synchronous session factory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine: SQLAlchemy Engine instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session factory.
|
||||||
|
"""
|
||||||
|
return sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def create_async_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Create an asynchronous session factory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine: SQLAlchemy AsyncEngine instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Async session factory.
|
||||||
|
"""
|
||||||
|
return async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(engine: Engine) -> None:
|
||||||
|
"""Initialize the database schema.
|
||||||
|
|
||||||
|
Creates all tables defined in the models.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine: SQLAlchemy Engine instance.
|
||||||
|
"""
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
logger.info("Database schema initialized")
|
||||||
|
|
||||||
|
|
||||||
|
async def init_async_db(engine: AsyncEngine) -> None:
|
||||||
|
"""Initialize the database schema asynchronously.
|
||||||
|
|
||||||
|
Creates all tables defined in the models.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine: SQLAlchemy AsyncEngine instance.
|
||||||
|
"""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
logger.info("Database schema initialized (async)")
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseManager:
|
||||||
|
"""Manages database connections and sessions.
|
||||||
|
|
||||||
|
Provides a unified interface for both sync and async database operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
database_url: str,
|
||||||
|
*,
|
||||||
|
async_mode: bool = True,
|
||||||
|
pool_size: int = 5,
|
||||||
|
max_overflow: int = 10,
|
||||||
|
echo: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize database manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_url: Database connection URL.
|
||||||
|
async_mode: Use async engine/sessions if True.
|
||||||
|
pool_size: Connection pool size.
|
||||||
|
max_overflow: Maximum overflow connections.
|
||||||
|
echo: Echo SQL statements for debugging.
|
||||||
|
"""
|
||||||
|
self.database_url = database_url
|
||||||
|
self.async_mode = async_mode
|
||||||
|
self._pool_size = pool_size
|
||||||
|
self._max_overflow = max_overflow
|
||||||
|
self._echo = echo
|
||||||
|
|
||||||
|
self._sync_engine: Engine | None = None
|
||||||
|
self._async_engine: AsyncEngine | None = None
|
||||||
|
self._sync_session_factory: sessionmaker[Session] | None = None
|
||||||
|
self._async_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
def _get_sync_engine(self) -> Engine:
|
||||||
|
"""Get or create the synchronous engine."""
|
||||||
|
if self._sync_engine is None:
|
||||||
|
self._sync_engine = create_sync_engine(
|
||||||
|
self.database_url,
|
||||||
|
pool_size=self._pool_size,
|
||||||
|
max_overflow=self._max_overflow,
|
||||||
|
echo=self._echo,
|
||||||
|
)
|
||||||
|
return self._sync_engine
|
||||||
|
|
||||||
|
def _get_async_engine(self) -> AsyncEngine:
|
||||||
|
"""Get or create the asynchronous engine."""
|
||||||
|
if self._async_engine is None:
|
||||||
|
self._async_engine = create_async_db_engine(
|
||||||
|
self.database_url,
|
||||||
|
pool_size=self._pool_size,
|
||||||
|
max_overflow=self._max_overflow,
|
||||||
|
echo=self._echo,
|
||||||
|
)
|
||||||
|
return self._async_engine
|
||||||
|
|
||||||
|
def get_sync_session(self) -> Session:
|
||||||
|
"""Get a new synchronous session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SQLAlchemy Session instance.
|
||||||
|
"""
|
||||||
|
if self._sync_session_factory is None:
|
||||||
|
self._sync_session_factory = create_sync_session_factory(self._get_sync_engine())
|
||||||
|
return self._sync_session_factory()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def get_async_session(self) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Get an asynchronous session as a context manager.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
SQLAlchemy AsyncSession instance.
|
||||||
|
"""
|
||||||
|
if self._async_session_factory is None:
|
||||||
|
self._async_session_factory = create_async_session_factory(self._get_async_engine())
|
||||||
|
|
||||||
|
session = self._async_session_factory()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
def init_schema(self) -> None:
|
||||||
|
"""Initialize database schema synchronously."""
|
||||||
|
init_db(self._get_sync_engine())
|
||||||
|
|
||||||
|
async def init_schema_async(self) -> None:
|
||||||
|
"""Initialize database schema asynchronously."""
|
||||||
|
await init_async_db(self._get_async_engine())
|
||||||
|
|
||||||
|
def dispose(self) -> None:
|
||||||
|
"""Dispose of all database connections."""
|
||||||
|
if self._sync_engine is not None:
|
||||||
|
self._sync_engine.dispose()
|
||||||
|
self._sync_engine = None
|
||||||
|
logger.info("Database connections disposed")
|
||||||
|
|
||||||
|
async def dispose_async(self) -> None:
|
||||||
|
"""Dispose of all async database connections."""
|
||||||
|
if self._async_engine is not None:
|
||||||
|
await self._async_engine.dispose()
|
||||||
|
self._async_engine = None
|
||||||
|
logger.info("Async database connections disposed")
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""SQLAlchemy models for persistent storage.
|
||||||
|
|
||||||
|
This module defines the database schema for storing wallet profiles,
|
||||||
|
funding transfers, and wallet relationships.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Base class for all SQLAlchemy models."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WalletProfileModel(Base):
|
||||||
|
"""SQLAlchemy model for wallet profiles.
|
||||||
|
|
||||||
|
Stores analyzed wallet information including age, transaction count,
|
||||||
|
balances, and freshness classification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "wallet_profiles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
address: Mapped[str] = mapped_column(String(42), unique=True, nullable=False)
|
||||||
|
nonce: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
first_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
is_fresh: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
|
matic_balance: Mapped[Decimal | None] = mapped_column(Numeric(30, 0), nullable=True)
|
||||||
|
usdc_balance: Mapped[Decimal | None] = mapped_column(Numeric(20, 6), nullable=True)
|
||||||
|
analyzed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (Index("idx_wallet_profiles_address", "address"),)
|
||||||
|
|
||||||
|
|
||||||
|
class FundingTransferModel(Base):
|
||||||
|
"""SQLAlchemy model for funding transfers.
|
||||||
|
|
||||||
|
Stores ERC20 transfer events to track wallet funding sources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "funding_transfers"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
from_address: Mapped[str] = mapped_column(String(42), nullable=False)
|
||||||
|
to_address: Mapped[str] = mapped_column(String(42), nullable=False)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(30, 6), nullable=False)
|
||||||
|
token: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||||
|
tx_hash: Mapped[str] = mapped_column(String(66), unique=True, nullable=False)
|
||||||
|
block_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_funding_transfers_to", "to_address"),
|
||||||
|
Index("idx_funding_transfers_from", "from_address"),
|
||||||
|
Index("idx_funding_transfers_block", "block_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WalletRelationshipModel(Base):
|
||||||
|
"""SQLAlchemy model for wallet relationships.
|
||||||
|
|
||||||
|
Stores graph edges between wallets representing funding relationships
|
||||||
|
or entity linkages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "wallet_relationships"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
wallet_a: Mapped[str] = mapped_column(String(42), nullable=False)
|
||||||
|
wallet_b: Mapped[str] = mapped_column(String(42), nullable=False)
|
||||||
|
relationship_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
confidence: Mapped[Decimal] = mapped_column(Numeric(3, 2), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("wallet_a", "wallet_b", "relationship_type", name="uq_wallet_relationship"),
|
||||||
|
Index("idx_wallet_relationships_a", "wallet_a"),
|
||||||
|
Index("idx_wallet_relationships_b", "wallet_b"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
"""Repository pattern implementations for data access.
|
||||||
|
|
||||||
|
This module provides clean data access abstractions for wallet profiles,
|
||||||
|
funding transfers, and wallet relationships.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select, update
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||||
|
|
||||||
|
from polymarket_insider_tracker.storage.models import (
|
||||||
|
FundingTransferModel,
|
||||||
|
WalletProfileModel,
|
||||||
|
WalletRelationshipModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WalletProfileDTO:
|
||||||
|
"""Data transfer object for wallet profiles."""
|
||||||
|
|
||||||
|
address: str
|
||||||
|
nonce: int
|
||||||
|
first_seen_at: datetime | None
|
||||||
|
is_fresh: bool
|
||||||
|
matic_balance: Decimal | None
|
||||||
|
usdc_balance: Decimal | None
|
||||||
|
analyzed_at: datetime
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_model(cls, model: WalletProfileModel) -> WalletProfileDTO:
|
||||||
|
"""Create DTO from SQLAlchemy model."""
|
||||||
|
return cls(
|
||||||
|
address=model.address,
|
||||||
|
nonce=model.nonce,
|
||||||
|
first_seen_at=model.first_seen_at,
|
||||||
|
is_fresh=model.is_fresh,
|
||||||
|
matic_balance=model.matic_balance,
|
||||||
|
usdc_balance=model.usdc_balance,
|
||||||
|
analyzed_at=model.analyzed_at,
|
||||||
|
created_at=model.created_at,
|
||||||
|
updated_at=model.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FundingTransferDTO:
|
||||||
|
"""Data transfer object for funding transfers."""
|
||||||
|
|
||||||
|
from_address: str
|
||||||
|
to_address: str
|
||||||
|
amount: Decimal
|
||||||
|
token: str
|
||||||
|
tx_hash: str
|
||||||
|
block_number: int
|
||||||
|
timestamp: datetime
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_model(cls, model: FundingTransferModel) -> FundingTransferDTO:
|
||||||
|
"""Create DTO from SQLAlchemy model."""
|
||||||
|
return cls(
|
||||||
|
from_address=model.from_address,
|
||||||
|
to_address=model.to_address,
|
||||||
|
amount=model.amount,
|
||||||
|
token=model.token,
|
||||||
|
tx_hash=model.tx_hash,
|
||||||
|
block_number=model.block_number,
|
||||||
|
timestamp=model.timestamp,
|
||||||
|
created_at=model.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WalletRelationshipDTO:
|
||||||
|
"""Data transfer object for wallet relationships."""
|
||||||
|
|
||||||
|
wallet_a: str
|
||||||
|
wallet_b: str
|
||||||
|
relationship_type: str
|
||||||
|
confidence: Decimal
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_model(cls, model: WalletRelationshipModel) -> WalletRelationshipDTO:
|
||||||
|
"""Create DTO from SQLAlchemy model."""
|
||||||
|
return cls(
|
||||||
|
wallet_a=model.wallet_a,
|
||||||
|
wallet_b=model.wallet_b,
|
||||||
|
relationship_type=model.relationship_type,
|
||||||
|
confidence=model.confidence,
|
||||||
|
created_at=model.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WalletRepository:
|
||||||
|
"""Repository for wallet profile data access.
|
||||||
|
|
||||||
|
Provides CRUD operations for wallet profiles with async support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
"""Initialize repository with database session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: SQLAlchemy async session.
|
||||||
|
"""
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_by_address(self, address: str) -> WalletProfileDTO | None:
|
||||||
|
"""Get wallet profile by address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Wallet address (lowercase).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WalletProfileDTO if found, None otherwise.
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(WalletProfileModel).where(WalletProfileModel.address == address.lower())
|
||||||
|
)
|
||||||
|
model = result.scalar_one_or_none()
|
||||||
|
return WalletProfileDTO.from_model(model) if model else None
|
||||||
|
|
||||||
|
async def get_many(self, addresses: list[str]) -> list[WalletProfileDTO]:
|
||||||
|
"""Get multiple wallet profiles by addresses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
addresses: List of wallet addresses.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WalletProfileDTOs for found addresses.
|
||||||
|
"""
|
||||||
|
normalized = [addr.lower() for addr in addresses]
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(WalletProfileModel).where(WalletProfileModel.address.in_(normalized))
|
||||||
|
)
|
||||||
|
return [WalletProfileDTO.from_model(m) for m in result.scalars().all()]
|
||||||
|
|
||||||
|
async def get_fresh_wallets(self, limit: int = 100) -> list[WalletProfileDTO]:
|
||||||
|
"""Get recent fresh wallets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WalletProfileDTOs marked as fresh.
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(WalletProfileModel)
|
||||||
|
.where(WalletProfileModel.is_fresh.is_(True))
|
||||||
|
.order_by(WalletProfileModel.analyzed_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [WalletProfileDTO.from_model(m) for m in result.scalars().all()]
|
||||||
|
|
||||||
|
async def upsert(self, dto: WalletProfileDTO) -> WalletProfileDTO:
|
||||||
|
"""Insert or update wallet profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dto: Wallet profile data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated WalletProfileDTO.
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
values = {
|
||||||
|
"address": dto.address.lower(),
|
||||||
|
"nonce": dto.nonce,
|
||||||
|
"first_seen_at": dto.first_seen_at,
|
||||||
|
"is_fresh": dto.is_fresh,
|
||||||
|
"matic_balance": dto.matic_balance,
|
||||||
|
"usdc_balance": dto.usdc_balance,
|
||||||
|
"analyzed_at": dto.analyzed_at,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try PostgreSQL upsert first, fall back to SQLite for testing
|
||||||
|
try:
|
||||||
|
stmt = pg_insert(WalletProfileModel).values(**values, created_at=now)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=["address"],
|
||||||
|
set_={
|
||||||
|
"nonce": stmt.excluded.nonce,
|
||||||
|
"first_seen_at": stmt.excluded.first_seen_at,
|
||||||
|
"is_fresh": stmt.excluded.is_fresh,
|
||||||
|
"matic_balance": stmt.excluded.matic_balance,
|
||||||
|
"usdc_balance": stmt.excluded.usdc_balance,
|
||||||
|
"analyzed_at": stmt.excluded.analyzed_at,
|
||||||
|
"updated_at": stmt.excluded.updated_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.session.execute(stmt)
|
||||||
|
except Exception:
|
||||||
|
# Fall back to SQLite upsert for testing
|
||||||
|
stmt = sqlite_insert(WalletProfileModel).values(**values, created_at=now)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=["address"],
|
||||||
|
set_={
|
||||||
|
"nonce": stmt.excluded.nonce,
|
||||||
|
"first_seen_at": stmt.excluded.first_seen_at,
|
||||||
|
"is_fresh": stmt.excluded.is_fresh,
|
||||||
|
"matic_balance": stmt.excluded.matic_balance,
|
||||||
|
"usdc_balance": stmt.excluded.usdc_balance,
|
||||||
|
"analyzed_at": stmt.excluded.analyzed_at,
|
||||||
|
"updated_at": stmt.excluded.updated_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.session.execute(stmt)
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
return dto
|
||||||
|
|
||||||
|
async def delete(self, address: str) -> bool:
|
||||||
|
"""Delete wallet profile by address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Wallet address.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
delete(WalletProfileModel).where(WalletProfileModel.address == address.lower())
|
||||||
|
)
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
async def mark_stale(self, address: str) -> bool:
|
||||||
|
"""Mark a wallet profile as stale (soft delete).
|
||||||
|
|
||||||
|
Sets analyzed_at to a very old date to trigger re-analysis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Wallet address.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if updated, False if not found.
|
||||||
|
"""
|
||||||
|
stale_time = datetime(2000, 1, 1, tzinfo=UTC)
|
||||||
|
result = await self.session.execute(
|
||||||
|
update(WalletProfileModel)
|
||||||
|
.where(WalletProfileModel.address == address.lower())
|
||||||
|
.values(analyzed_at=stale_time, updated_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
class FundingRepository:
|
||||||
|
"""Repository for funding transfer data access.
|
||||||
|
|
||||||
|
Provides CRUD operations for funding transfers with async support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
"""Initialize repository with database session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: SQLAlchemy async session.
|
||||||
|
"""
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_transfers_to(
|
||||||
|
self, address: str, limit: int = 100
|
||||||
|
) -> list[FundingTransferDTO]:
|
||||||
|
"""Get transfers to a wallet address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Destination wallet address.
|
||||||
|
limit: Maximum number of results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of FundingTransferDTOs ordered by timestamp.
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(FundingTransferModel)
|
||||||
|
.where(FundingTransferModel.to_address == address.lower())
|
||||||
|
.order_by(FundingTransferModel.timestamp.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [FundingTransferDTO.from_model(m) for m in result.scalars().all()]
|
||||||
|
|
||||||
|
async def get_transfers_from(
|
||||||
|
self, address: str, limit: int = 100
|
||||||
|
) -> list[FundingTransferDTO]:
|
||||||
|
"""Get transfers from a wallet address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Source wallet address.
|
||||||
|
limit: Maximum number of results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of FundingTransferDTOs ordered by timestamp.
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(FundingTransferModel)
|
||||||
|
.where(FundingTransferModel.from_address == address.lower())
|
||||||
|
.order_by(FundingTransferModel.timestamp.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [FundingTransferDTO.from_model(m) for m in result.scalars().all()]
|
||||||
|
|
||||||
|
async def get_first_transfer_to(self, address: str) -> FundingTransferDTO | None:
|
||||||
|
"""Get the first transfer to a wallet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Wallet address.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
First FundingTransferDTO if found, None otherwise.
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(FundingTransferModel)
|
||||||
|
.where(FundingTransferModel.to_address == address.lower())
|
||||||
|
.order_by(FundingTransferModel.timestamp.asc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
model = result.scalar_one_or_none()
|
||||||
|
return FundingTransferDTO.from_model(model) if model else None
|
||||||
|
|
||||||
|
async def get_by_tx_hash(self, tx_hash: str) -> FundingTransferDTO | None:
|
||||||
|
"""Get transfer by transaction hash.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tx_hash: Transaction hash.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FundingTransferDTO if found, None otherwise.
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(FundingTransferModel).where(FundingTransferModel.tx_hash == tx_hash.lower())
|
||||||
|
)
|
||||||
|
model = result.scalar_one_or_none()
|
||||||
|
return FundingTransferDTO.from_model(model) if model else None
|
||||||
|
|
||||||
|
async def insert(self, dto: FundingTransferDTO) -> FundingTransferDTO:
|
||||||
|
"""Insert a new funding transfer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dto: Funding transfer data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inserted FundingTransferDTO.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
IntegrityError if tx_hash already exists.
|
||||||
|
"""
|
||||||
|
model = FundingTransferModel(
|
||||||
|
from_address=dto.from_address.lower(),
|
||||||
|
to_address=dto.to_address.lower(),
|
||||||
|
amount=dto.amount,
|
||||||
|
token=dto.token,
|
||||||
|
tx_hash=dto.tx_hash.lower(),
|
||||||
|
block_number=dto.block_number,
|
||||||
|
timestamp=dto.timestamp,
|
||||||
|
)
|
||||||
|
self.session.add(model)
|
||||||
|
await self.session.flush()
|
||||||
|
return dto
|
||||||
|
|
||||||
|
async def insert_many(self, dtos: list[FundingTransferDTO]) -> int:
|
||||||
|
"""Insert multiple funding transfers.
|
||||||
|
|
||||||
|
Skips duplicates silently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dtos: List of funding transfer data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of transfers inserted.
|
||||||
|
"""
|
||||||
|
inserted = 0
|
||||||
|
for dto in dtos:
|
||||||
|
try:
|
||||||
|
await self.insert(dto)
|
||||||
|
inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
# Skip duplicates
|
||||||
|
if "UNIQUE constraint" in str(e) or "duplicate key" in str(e).lower():
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipRepository:
|
||||||
|
"""Repository for wallet relationship data access.
|
||||||
|
|
||||||
|
Provides CRUD operations for wallet relationships with async support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
"""Initialize repository with database session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: SQLAlchemy async session.
|
||||||
|
"""
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_relationships(
|
||||||
|
self, wallet: str, relationship_type: str | None = None
|
||||||
|
) -> list[WalletRelationshipDTO]:
|
||||||
|
"""Get relationships for a wallet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wallet: Wallet address.
|
||||||
|
relationship_type: Optional filter by type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WalletRelationshipDTOs.
|
||||||
|
"""
|
||||||
|
stmt = select(WalletRelationshipModel).where(
|
||||||
|
(WalletRelationshipModel.wallet_a == wallet.lower())
|
||||||
|
| (WalletRelationshipModel.wallet_b == wallet.lower())
|
||||||
|
)
|
||||||
|
if relationship_type:
|
||||||
|
stmt = stmt.where(WalletRelationshipModel.relationship_type == relationship_type)
|
||||||
|
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return [WalletRelationshipDTO.from_model(m) for m in result.scalars().all()]
|
||||||
|
|
||||||
|
async def get_related_wallets(
|
||||||
|
self, wallet: str, relationship_type: str | None = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""Get addresses of related wallets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wallet: Wallet address.
|
||||||
|
relationship_type: Optional filter by type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of related wallet addresses.
|
||||||
|
"""
|
||||||
|
relationships = await self.get_relationships(wallet, relationship_type)
|
||||||
|
related = set()
|
||||||
|
normalized = wallet.lower()
|
||||||
|
for rel in relationships:
|
||||||
|
if rel.wallet_a == normalized:
|
||||||
|
related.add(rel.wallet_b)
|
||||||
|
else:
|
||||||
|
related.add(rel.wallet_a)
|
||||||
|
return list(related)
|
||||||
|
|
||||||
|
async def upsert(self, dto: WalletRelationshipDTO) -> WalletRelationshipDTO:
|
||||||
|
"""Insert or update wallet relationship.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dto: Wallet relationship data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated WalletRelationshipDTO.
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
values = {
|
||||||
|
"wallet_a": dto.wallet_a.lower(),
|
||||||
|
"wallet_b": dto.wallet_b.lower(),
|
||||||
|
"relationship_type": dto.relationship_type,
|
||||||
|
"confidence": dto.confidence,
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try PostgreSQL upsert first, fall back to SQLite for testing
|
||||||
|
try:
|
||||||
|
stmt = pg_insert(WalletRelationshipModel).values(**values)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
constraint="uq_wallet_relationship",
|
||||||
|
set_={"confidence": stmt.excluded.confidence},
|
||||||
|
)
|
||||||
|
await self.session.execute(stmt)
|
||||||
|
except Exception:
|
||||||
|
# Fall back to SQLite upsert for testing
|
||||||
|
stmt = sqlite_insert(WalletRelationshipModel).values(**values)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=["wallet_a", "wallet_b", "relationship_type"],
|
||||||
|
set_={"confidence": stmt.excluded.confidence},
|
||||||
|
)
|
||||||
|
await self.session.execute(stmt)
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
return dto
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self, wallet_a: str, wallet_b: str, relationship_type: str
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a specific relationship.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wallet_a: First wallet address.
|
||||||
|
wallet_b: Second wallet address.
|
||||||
|
relationship_type: Type of relationship.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
delete(WalletRelationshipModel).where(
|
||||||
|
WalletRelationshipModel.wallet_a == wallet_a.lower(),
|
||||||
|
WalletRelationshipModel.wallet_b == wallet_b.lower(),
|
||||||
|
WalletRelationshipModel.relationship_type == relationship_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.rowcount > 0
|
||||||
@@ -1 +1 @@
|
|||||||
"""Tests for storage module."""
|
"""Tests for the storage layer."""
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
"""Tests for storage repositories."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from polymarket_insider_tracker.storage.models import Base
|
||||||
|
from polymarket_insider_tracker.storage.repos import (
|
||||||
|
FundingRepository,
|
||||||
|
FundingTransferDTO,
|
||||||
|
RelationshipRepository,
|
||||||
|
WalletProfileDTO,
|
||||||
|
WalletRelationshipDTO,
|
||||||
|
WalletRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def async_engine():
|
||||||
|
"""Create an async SQLite engine for testing."""
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield engine
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def async_session(async_engine) -> AsyncSession:
|
||||||
|
"""Create an async session for testing."""
|
||||||
|
session_factory = async_sessionmaker(bind=async_engine, expire_on_commit=False)
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_wallet_dto() -> WalletProfileDTO:
|
||||||
|
"""Create a sample wallet profile DTO."""
|
||||||
|
return WalletProfileDTO(
|
||||||
|
address="0x1234567890abcdef1234567890abcdef12345678",
|
||||||
|
nonce=5,
|
||||||
|
first_seen_at=datetime.now(UTC) - timedelta(hours=24),
|
||||||
|
is_fresh=True,
|
||||||
|
matic_balance=Decimal("1000000000000000000"),
|
||||||
|
usdc_balance=Decimal("1000.00"),
|
||||||
|
analyzed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_transfer_dto() -> FundingTransferDTO:
|
||||||
|
"""Create a sample funding transfer DTO."""
|
||||||
|
return FundingTransferDTO(
|
||||||
|
from_address="0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
to_address="0x1234567890abcdef1234567890abcdef12345678",
|
||||||
|
amount=Decimal("5000.00"),
|
||||||
|
token="USDC",
|
||||||
|
tx_hash="0x" + "a" * 64,
|
||||||
|
block_number=12345678,
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_relationship_dto() -> WalletRelationshipDTO:
|
||||||
|
"""Create a sample wallet relationship DTO."""
|
||||||
|
return WalletRelationshipDTO(
|
||||||
|
wallet_a="0x1234567890abcdef1234567890abcdef12345678",
|
||||||
|
wallet_b="0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
relationship_type="funded_by",
|
||||||
|
confidence=Decimal("0.95"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# WalletRepository Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestWalletRepository:
|
||||||
|
"""Tests for WalletRepository."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_by_address_not_found(self, async_session: AsyncSession) -> None:
|
||||||
|
"""Test getting a non-existent wallet returns None."""
|
||||||
|
repo = WalletRepository(async_session)
|
||||||
|
result = await repo.get_by_address("0xnonexistent")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upsert_creates_new(
|
||||||
|
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test upserting a new wallet profile."""
|
||||||
|
repo = WalletRepository(async_session)
|
||||||
|
await repo.upsert(sample_wallet_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
result = await repo.get_by_address(sample_wallet_dto.address)
|
||||||
|
assert result is not None
|
||||||
|
assert result.address == sample_wallet_dto.address.lower()
|
||||||
|
assert result.nonce == sample_wallet_dto.nonce
|
||||||
|
assert result.is_fresh == sample_wallet_dto.is_fresh
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upsert_updates_existing(
|
||||||
|
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test upserting updates existing profile."""
|
||||||
|
repo = WalletRepository(async_session)
|
||||||
|
await repo.upsert(sample_wallet_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
# Update the DTO
|
||||||
|
updated_dto = WalletProfileDTO(
|
||||||
|
address=sample_wallet_dto.address,
|
||||||
|
nonce=10,
|
||||||
|
first_seen_at=sample_wallet_dto.first_seen_at,
|
||||||
|
is_fresh=False,
|
||||||
|
matic_balance=sample_wallet_dto.matic_balance,
|
||||||
|
usdc_balance=Decimal("2000.00"),
|
||||||
|
analyzed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
await repo.upsert(updated_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
result = await repo.get_by_address(sample_wallet_dto.address)
|
||||||
|
assert result is not None
|
||||||
|
assert result.nonce == 10
|
||||||
|
assert result.is_fresh is False
|
||||||
|
assert result.usdc_balance == Decimal("2000.00")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_many(
|
||||||
|
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test getting multiple wallets."""
|
||||||
|
repo = WalletRepository(async_session)
|
||||||
|
|
||||||
|
# Insert two wallets
|
||||||
|
dto2 = WalletProfileDTO(
|
||||||
|
address="0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
|
nonce=3,
|
||||||
|
first_seen_at=datetime.now(UTC),
|
||||||
|
is_fresh=True,
|
||||||
|
matic_balance=None,
|
||||||
|
usdc_balance=None,
|
||||||
|
analyzed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
await repo.upsert(sample_wallet_dto)
|
||||||
|
await repo.upsert(dto2)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
results = await repo.get_many([sample_wallet_dto.address, dto2.address])
|
||||||
|
assert len(results) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_fresh_wallets(
|
||||||
|
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test getting fresh wallets."""
|
||||||
|
repo = WalletRepository(async_session)
|
||||||
|
|
||||||
|
# Insert fresh and non-fresh wallets
|
||||||
|
non_fresh = WalletProfileDTO(
|
||||||
|
address="0xcccccccccccccccccccccccccccccccccccccccc",
|
||||||
|
nonce=100,
|
||||||
|
first_seen_at=datetime.now(UTC) - timedelta(days=30),
|
||||||
|
is_fresh=False,
|
||||||
|
matic_balance=None,
|
||||||
|
usdc_balance=None,
|
||||||
|
analyzed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
await repo.upsert(sample_wallet_dto)
|
||||||
|
await repo.upsert(non_fresh)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
results = await repo.get_fresh_wallets()
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].is_fresh is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete(
|
||||||
|
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test deleting a wallet profile."""
|
||||||
|
repo = WalletRepository(async_session)
|
||||||
|
await repo.upsert(sample_wallet_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
deleted = await repo.delete(sample_wallet_dto.address)
|
||||||
|
await async_session.commit()
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
result = await repo.get_by_address(sample_wallet_dto.address)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_not_found(self, async_session: AsyncSession) -> None:
|
||||||
|
"""Test deleting non-existent wallet returns False."""
|
||||||
|
repo = WalletRepository(async_session)
|
||||||
|
deleted = await repo.delete("0xnonexistent")
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_stale(
|
||||||
|
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test marking a wallet as stale."""
|
||||||
|
repo = WalletRepository(async_session)
|
||||||
|
await repo.upsert(sample_wallet_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
marked = await repo.mark_stale(sample_wallet_dto.address)
|
||||||
|
await async_session.commit()
|
||||||
|
assert marked is True
|
||||||
|
|
||||||
|
result = await repo.get_by_address(sample_wallet_dto.address)
|
||||||
|
assert result is not None
|
||||||
|
assert result.analyzed_at.year == 2000
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# FundingRepository Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestFundingRepository:
|
||||||
|
"""Tests for FundingRepository."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_insert(
|
||||||
|
self, async_session: AsyncSession, sample_transfer_dto: FundingTransferDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test inserting a funding transfer."""
|
||||||
|
repo = FundingRepository(async_session)
|
||||||
|
await repo.insert(sample_transfer_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
result = await repo.get_by_tx_hash(sample_transfer_dto.tx_hash)
|
||||||
|
assert result is not None
|
||||||
|
assert result.amount == sample_transfer_dto.amount
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_transfers_to(
|
||||||
|
self, async_session: AsyncSession, sample_transfer_dto: FundingTransferDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test getting transfers to an address."""
|
||||||
|
repo = FundingRepository(async_session)
|
||||||
|
await repo.insert(sample_transfer_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
results = await repo.get_transfers_to(sample_transfer_dto.to_address)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].from_address == sample_transfer_dto.from_address.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_transfers_from(
|
||||||
|
self, async_session: AsyncSession, sample_transfer_dto: FundingTransferDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test getting transfers from an address."""
|
||||||
|
repo = FundingRepository(async_session)
|
||||||
|
await repo.insert(sample_transfer_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
results = await repo.get_transfers_from(sample_transfer_dto.from_address)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].to_address == sample_transfer_dto.to_address.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_first_transfer_to(
|
||||||
|
self, async_session: AsyncSession, sample_transfer_dto: FundingTransferDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test getting first transfer to an address."""
|
||||||
|
repo = FundingRepository(async_session)
|
||||||
|
|
||||||
|
# Insert multiple transfers with different timestamps
|
||||||
|
earlier = FundingTransferDTO(
|
||||||
|
from_address="0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||||
|
to_address=sample_transfer_dto.to_address,
|
||||||
|
amount=Decimal("100.00"),
|
||||||
|
token="USDC",
|
||||||
|
tx_hash="0x" + "b" * 64,
|
||||||
|
block_number=12345670,
|
||||||
|
timestamp=datetime.now(UTC) - timedelta(hours=2),
|
||||||
|
)
|
||||||
|
await repo.insert(earlier)
|
||||||
|
await repo.insert(sample_transfer_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
result = await repo.get_first_transfer_to(sample_transfer_dto.to_address)
|
||||||
|
assert result is not None
|
||||||
|
assert result.tx_hash == earlier.tx_hash.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_insert_many(self, async_session: AsyncSession) -> None:
|
||||||
|
"""Test inserting multiple transfers."""
|
||||||
|
repo = FundingRepository(async_session)
|
||||||
|
|
||||||
|
transfers = [
|
||||||
|
FundingTransferDTO(
|
||||||
|
from_address=f"0x{'a' * 40}",
|
||||||
|
to_address=f"0x{'b' * 40}",
|
||||||
|
amount=Decimal(f"{i * 100}.00"),
|
||||||
|
token="USDC",
|
||||||
|
tx_hash=f"0x{str(i) * 64}"[:66],
|
||||||
|
block_number=12345678 + i,
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
for i in range(1, 4)
|
||||||
|
]
|
||||||
|
|
||||||
|
count = await repo.insert_many(transfers)
|
||||||
|
await async_session.commit()
|
||||||
|
assert count == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# RelationshipRepository Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestRelationshipRepository:
|
||||||
|
"""Tests for RelationshipRepository."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upsert(
|
||||||
|
self, async_session: AsyncSession, sample_relationship_dto: WalletRelationshipDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test upserting a relationship."""
|
||||||
|
repo = RelationshipRepository(async_session)
|
||||||
|
await repo.upsert(sample_relationship_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
results = await repo.get_relationships(sample_relationship_dto.wallet_a)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].confidence == sample_relationship_dto.confidence
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_relationships_filter_type(
|
||||||
|
self, async_session: AsyncSession, sample_relationship_dto: WalletRelationshipDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test getting relationships with type filter."""
|
||||||
|
repo = RelationshipRepository(async_session)
|
||||||
|
await repo.upsert(sample_relationship_dto)
|
||||||
|
|
||||||
|
same_entity = WalletRelationshipDTO(
|
||||||
|
wallet_a=sample_relationship_dto.wallet_a,
|
||||||
|
wallet_b="0xdddddddddddddddddddddddddddddddddddddddd",
|
||||||
|
relationship_type="same_entity",
|
||||||
|
confidence=Decimal("0.80"),
|
||||||
|
)
|
||||||
|
await repo.upsert(same_entity)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
funded_results = await repo.get_relationships(
|
||||||
|
sample_relationship_dto.wallet_a, relationship_type="funded_by"
|
||||||
|
)
|
||||||
|
assert len(funded_results) == 1
|
||||||
|
assert funded_results[0].relationship_type == "funded_by"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_related_wallets(
|
||||||
|
self, async_session: AsyncSession, sample_relationship_dto: WalletRelationshipDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test getting related wallet addresses."""
|
||||||
|
repo = RelationshipRepository(async_session)
|
||||||
|
await repo.upsert(sample_relationship_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
related = await repo.get_related_wallets(sample_relationship_dto.wallet_a)
|
||||||
|
assert len(related) == 1
|
||||||
|
assert sample_relationship_dto.wallet_b.lower() in related
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_relationship(
|
||||||
|
self, async_session: AsyncSession, sample_relationship_dto: WalletRelationshipDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test deleting a relationship."""
|
||||||
|
repo = RelationshipRepository(async_session)
|
||||||
|
await repo.upsert(sample_relationship_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
deleted = await repo.delete(
|
||||||
|
sample_relationship_dto.wallet_a,
|
||||||
|
sample_relationship_dto.wallet_b,
|
||||||
|
sample_relationship_dto.relationship_type,
|
||||||
|
)
|
||||||
|
await async_session.commit()
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
results = await repo.get_relationships(sample_relationship_dto.wallet_a)
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upsert_updates_confidence(
|
||||||
|
self, async_session: AsyncSession, sample_relationship_dto: WalletRelationshipDTO
|
||||||
|
) -> None:
|
||||||
|
"""Test that upserting updates the confidence."""
|
||||||
|
repo = RelationshipRepository(async_session)
|
||||||
|
await repo.upsert(sample_relationship_dto)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
updated = WalletRelationshipDTO(
|
||||||
|
wallet_a=sample_relationship_dto.wallet_a,
|
||||||
|
wallet_b=sample_relationship_dto.wallet_b,
|
||||||
|
relationship_type=sample_relationship_dto.relationship_type,
|
||||||
|
confidence=Decimal("0.99"),
|
||||||
|
)
|
||||||
|
await repo.upsert(updated)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
results = await repo.get_relationships(sample_relationship_dto.wallet_a)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].confidence == Decimal("0.99")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DTO Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestDTOs:
|
||||||
|
"""Tests for Data Transfer Objects."""
|
||||||
|
|
||||||
|
def test_wallet_profile_dto_from_model(self) -> None:
|
||||||
|
"""Test WalletProfileDTO.from_model works correctly."""
|
||||||
|
from polymarket_insider_tracker.storage.models import WalletProfileModel
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
model = WalletProfileModel(
|
||||||
|
id=1,
|
||||||
|
address="0x1234",
|
||||||
|
nonce=5,
|
||||||
|
first_seen_at=now,
|
||||||
|
is_fresh=True,
|
||||||
|
matic_balance=Decimal("100"),
|
||||||
|
usdc_balance=Decimal("50.00"),
|
||||||
|
analyzed_at=now,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
dto = WalletProfileDTO.from_model(model)
|
||||||
|
assert dto.address == "0x1234"
|
||||||
|
assert dto.nonce == 5
|
||||||
|
assert dto.is_fresh is True
|
||||||
|
|
||||||
|
def test_funding_transfer_dto_from_model(self) -> None:
|
||||||
|
"""Test FundingTransferDTO.from_model works correctly."""
|
||||||
|
from polymarket_insider_tracker.storage.models import FundingTransferModel
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
model = FundingTransferModel(
|
||||||
|
id=1,
|
||||||
|
from_address="0xaaa",
|
||||||
|
to_address="0xbbb",
|
||||||
|
amount=Decimal("100.00"),
|
||||||
|
token="USDC",
|
||||||
|
tx_hash="0x123",
|
||||||
|
block_number=12345,
|
||||||
|
timestamp=now,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
dto = FundingTransferDTO.from_model(model)
|
||||||
|
assert dto.from_address == "0xaaa"
|
||||||
|
assert dto.amount == Decimal("100.00")
|
||||||
|
|
||||||
|
def test_wallet_relationship_dto_from_model(self) -> None:
|
||||||
|
"""Test WalletRelationshipDTO.from_model works correctly."""
|
||||||
|
from polymarket_insider_tracker.storage.models import WalletRelationshipModel
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
model = WalletRelationshipModel(
|
||||||
|
id=1,
|
||||||
|
wallet_a="0xaaa",
|
||||||
|
wallet_b="0xbbb",
|
||||||
|
relationship_type="funded_by",
|
||||||
|
confidence=Decimal("0.95"),
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
dto = WalletRelationshipDTO.from_model(model)
|
||||||
|
assert dto.wallet_a == "0xaaa"
|
||||||
|
assert dto.relationship_type == "funded_by"
|
||||||
|
assert dto.confidence == Decimal("0.95")
|
||||||
Reference in New Issue
Block a user