From 72cb1b6fe53e779374d01e594ab495dae8b7401f Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 22 Feb 2026 15:17:07 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20database=20models=20and=20alembic=20mig?= =?UTF-8?q?rations=20=E2=80=94=20all=20tables=20per=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared/db.py: async engine + session factory - shared/models/base.py: DeclarativeBase + TimestampMixin - shared/models/trading.py: Strategy, Signal, Trade, Position, StrategyWeightHistory - shared/models/news.py: Article, ArticleSentiment - shared/models/learning.py: TradeOutcome, LearningAdjustment - shared/models/auth.py: User, UserCredential - shared/models/timeseries.py: MarketData, PortfolioSnapshot, StrategyMetric - Alembic async env.py with initial migration including TimescaleDB hypertables - 21 model tests covering enums, instantiation, metadata registration --- alembic.ini | 151 ++++++++ alembic/README | 1 + alembic/__pycache__/env.cpython-314.pyc | Bin 0 -> 4054 bytes alembic/env.py | 71 ++++ alembic/script.py.mako | 28 ++ ...1b2c3d4e5f6_initial_schema.cpython-314.pyc | Bin 0 -> 1778 bytes .../versions/a1b2c3d4e5f6_initial_schema.py | 284 +++++++++++++++ shared/db.py | 22 ++ shared/models/__init__.py | 44 +++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 1064 bytes .../models/__pycache__/auth.cpython-314.pyc | Bin 0 -> 3028 bytes .../models/__pycache__/base.cpython-314.pyc | Bin 0 -> 1650 bytes .../__pycache__/learning.cpython-314.pyc | Bin 0 -> 3637 bytes .../models/__pycache__/news.cpython-314.pyc | Bin 0 -> 3623 bytes .../__pycache__/timeseries.cpython-314.pyc | Bin 0 -> 4259 bytes .../__pycache__/trading.cpython-314.pyc | Bin 0 -> 9644 bytes shared/models/auth.py | 39 +++ shared/models/base.py | 26 ++ shared/models/learning.py | 51 +++ shared/models/news.py | 49 +++ shared/models/timeseries.py | 57 ++++ shared/models/trading.py | 137 ++++++++ tests/test_models.py | 323 ++++++++++++++++++ 23 files changed, 1283 insertions(+) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/__pycache__/env.cpython-314.pyc create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/__pycache__/a1b2c3d4e5f6_initial_schema.cpython-314.pyc create mode 100644 alembic/versions/a1b2c3d4e5f6_initial_schema.py create mode 100644 shared/db.py create mode 100644 shared/models/__init__.py create mode 100644 shared/models/__pycache__/__init__.cpython-314.pyc create mode 100644 shared/models/__pycache__/auth.cpython-314.pyc create mode 100644 shared/models/__pycache__/base.cpython-314.pyc create mode 100644 shared/models/__pycache__/learning.cpython-314.pyc create mode 100644 shared/models/__pycache__/news.cpython-314.pyc create mode 100644 shared/models/__pycache__/timeseries.cpython-314.pyc create mode 100644 shared/models/__pycache__/trading.cpython-314.pyc create mode 100644 shared/models/auth.py create mode 100644 shared/models/base.py create mode 100644 shared/models/learning.py create mode 100644 shared/models/news.py create mode 100644 shared/models/timeseries.py create mode 100644 shared/models/trading.py create mode 100644 tests/test_models.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..9183524 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,151 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# 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, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# Database URL is read from the TRADING_DATABASE_URL environment variable +# in alembic/env.py. The value here is a fallback only. +sqlalchemy.url = postgresql+asyncpg://trading:trading@localhost:5432/trading + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/__pycache__/env.cpython-314.pyc b/alembic/__pycache__/env.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..489a3fc1b7e6ae0088265f8aab30e133f4680d9d GIT binary patch literal 4054 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 Tdm>~W3w?hVPCpiU*+KprGW9Ic literal 0 HcmV?d00001 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..60b9094 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,71 @@ +"""Alembic environment — async-aware, imports all shared models.""" + +import asyncio +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +# Import all models so metadata is populated for autogenerate. +from shared.models import Base # noqa: F401 +import shared.models # noqa: F401 — triggers side-effect imports of every model + +config = context.config + +# Override sqlalchemy.url from environment if available. +db_url = os.environ.get("TRADING_DATABASE_URL") +if db_url: + config.set_main_option("sqlalchemy.url", db_url) + +# Interpret the config file for Python logging. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode (emit SQL without a live connection).""" + 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 do_run_migrations(connection) -> None: # noqa: ANN001 + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode using an async engine.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Entry-point for online migrations — delegates to the async helper.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${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, Sequence[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: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/__pycache__/a1b2c3d4e5f6_initial_schema.cpython-314.pyc b/alembic/versions/__pycache__/a1b2c3d4e5f6_initial_schema.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7096b5dd2480e3433dd64aca4987570eef2c7c7b GIT binary patch literal 1778 zcmcIk&2Jk;6o0d8ubr)(51Q6|xUpMHfLrW!HnG(Pgb<`sa*4!N;goi@-kl_?Ye9D3`T-uDg7_;<3JCru>$ak25_)|lRIFMWo1d_>QaiQN`PZ!IlZKD zP3r1$hG(r5AG?h!TciY*%5~JqP(O#dGSqXZt5Q!Fz|sm3O!B;yK|L$=aZ9a0Ro}bq zhd%e6pd7oKblcH&i|+WbABE-R#f7pn+cw=fZ=N>STZ0uEFO>C97v_@~O94vu2~E>s2Mm-lBKAG<0c_UJY^IDoK>6vy$Y_?(Gtchj@jEAgTEF ziwHgV3`t$T58&Zk5k4iG#3GhrkP19he;2li#whQyaOEN3=GuD1wjCae-L52EV>PBM zHg^0i9Tck4aqeEkwm73SHk=I_@|v?gf4zO9)i9iZ zZnu5c*ulM!1FoM5j6DpYJqTgFvzz2>+X=&nV}NX%1>uLGv z<21MeMj{T6bW?o|70ePOTUGWZRy!MbYC62D`U(@7Hh^)Vi@->W$;(&s!G^7)CU2UE+s(;Z;FNISk6n91LtV)OkT`#YB9PO{s*38-pGzk zD5Pits2kQ?~%FtSBh8`UekuUO=5oWDcOAUWL z^T(RFIw}9`@}4;4P%LFmJv&AZs`Q||LJukFL4AcD(!v8~tH!dZBw03WCo4%_+?PEX zC;2vWLU+>+oHh;OME7WihF)xAhe#!F*|r;n9(JZEbb?VWiORF>M2$IP;y#(Uo|mPg zNU-=mf+#wTq)r5!7J-zGU}nE^2s0;_#LNtk))6%Ky9c+vd-uWI_q89VA6=+Do~R!} z^Q0s!$}h9HEG(`7nL7e=|K>sE;PVISH(&hVJ$mixHRz8g4!=*_!{55Lt$T z{6~Y9ko{mg^11>2kX^<}8I_Mw`Rj>F2>DYH#~%k$`3)}q4236Im@4(M$1wBLY_pd? XhUT+x-R$YdVE&f~HB&WXtREt}~ literal 0 HcmV?d00001 diff --git a/alembic/versions/a1b2c3d4e5f6_initial_schema.py b/alembic/versions/a1b2c3d4e5f6_initial_schema.py new file mode 100644 index 0000000..3c71fea --- /dev/null +++ b/alembic/versions/a1b2c3d4e5f6_initial_schema.py @@ -0,0 +1,284 @@ +"""initial schema + +Revision ID: a1b2c3d4e5f6 +Revises: +Create Date: 2026-02-22 15:15:15.661206 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create all tables for the trading bot.""" + + # --- Core trading tables --- + + op.create_table( + "strategies", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(255), unique=True, nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("current_weight", sa.Float, nullable=False, server_default="0.333"), + sa.Column("active", sa.Boolean, nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "signals", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("ticker", sa.String(20), nullable=False, index=True), + sa.Column( + "direction", + sa.Enum("LONG", "SHORT", "NEUTRAL", name="signaldirection"), + nullable=False, + ), + sa.Column("strength", sa.Float, nullable=False), + sa.Column("strategy_sources", postgresql.JSON, nullable=True), + sa.Column("sentiment_score", sa.Float, nullable=True), + sa.Column("acted_on", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column( + "strategy_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("strategies.id"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "trades", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("ticker", sa.String(20), nullable=False, index=True), + sa.Column( + "side", + sa.Enum("BUY", "SELL", name="tradeside"), + nullable=False, + ), + sa.Column("qty", sa.Float, nullable=False), + sa.Column("price", sa.Float, nullable=False), + sa.Column("timestamp", sa.String, nullable=True), + sa.Column( + "strategy_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("strategies.id"), + nullable=True, + ), + sa.Column( + "signal_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("signals.id"), + nullable=True, + ), + sa.Column( + "status", + sa.Enum("PENDING", "FILLED", "CANCELLED", "REJECTED", name="tradestatus"), + nullable=False, + server_default="PENDING", + ), + sa.Column("pnl", sa.Float, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "positions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("ticker", sa.String(20), unique=True, nullable=False), + sa.Column("qty", sa.Float, nullable=False), + sa.Column("avg_entry", sa.Float, nullable=False), + sa.Column("unrealized_pnl", sa.Float, nullable=True), + sa.Column("stop_loss", sa.Float, nullable=True), + sa.Column("take_profit", sa.Float, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "strategy_weight_history", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "strategy_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("strategies.id"), + nullable=False, + ), + sa.Column("old_weight", sa.Float, nullable=False), + sa.Column("new_weight", sa.Float, nullable=False), + sa.Column("reason", sa.String(500), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- News & sentiment --- + + op.create_table( + "articles", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("source", sa.String(100), nullable=False), + sa.Column("url", sa.Text, nullable=False), + sa.Column("title", sa.Text, nullable=False), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("content_hash", sa.String(64), unique=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "article_sentiments", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "article_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("articles.id"), + nullable=False, + ), + sa.Column("ticker", sa.String(20), nullable=False, index=True), + sa.Column("score", sa.Float, nullable=False), + sa.Column("confidence", sa.Float, nullable=False), + sa.Column("model_used", sa.String(50), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- Learning --- + + op.create_table( + "trade_outcomes", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "trade_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("trades.id"), + unique=True, + nullable=False, + ), + sa.Column("hold_duration", sa.Interval, nullable=True), + sa.Column("realized_pnl", sa.Float, nullable=False), + sa.Column("roi_pct", sa.Float, nullable=False), + sa.Column("was_profitable", sa.Boolean, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "learning_adjustments", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "strategy_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("strategies.id"), + nullable=False, + ), + sa.Column("old_weight", sa.Float, nullable=False), + sa.Column("new_weight", sa.Float, nullable=False), + sa.Column("reason", sa.Text, nullable=True), + sa.Column("reward_signal", sa.Float, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- Auth --- + + op.create_table( + "users", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("username", sa.String(100), unique=True, nullable=False), + sa.Column("display_name", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "user_credentials", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id"), + nullable=False, + ), + sa.Column("credential_id", sa.String(512), unique=True, nullable=False), + sa.Column("public_key", sa.LargeBinary, nullable=False), + sa.Column("sign_count", sa.Integer, nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- Timeseries (TimescaleDB hypertables) --- + + op.create_table( + "market_data", + sa.Column("timestamp", sa.DateTime(timezone=True), primary_key=True), + sa.Column("ticker", sa.String(20), primary_key=True), + sa.Column("open", sa.Float, nullable=False), + sa.Column("high", sa.Float, nullable=False), + sa.Column("low", sa.Float, nullable=False), + sa.Column("close", sa.Float, nullable=False), + sa.Column("volume", sa.Float, nullable=False), + ) + + op.create_table( + "portfolio_snapshots", + sa.Column("timestamp", sa.DateTime(timezone=True), primary_key=True), + sa.Column("total_value", sa.Float, nullable=False), + sa.Column("cash", sa.Float, nullable=False), + sa.Column("positions_value", sa.Float, nullable=False), + sa.Column("daily_pnl", sa.Float, nullable=False), + ) + + op.create_table( + "strategy_metrics", + sa.Column("timestamp", sa.DateTime(timezone=True), primary_key=True), + sa.Column( + "strategy_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("strategies.id"), + primary_key=True, + ), + sa.Column("win_rate", sa.Float, nullable=False), + sa.Column("total_pnl", sa.Float, nullable=False), + sa.Column("trade_count", sa.Integer, nullable=False), + sa.Column("sharpe_ratio", sa.Float, nullable=True), + ) + + # Convert timeseries tables to TimescaleDB hypertables. + # These calls are idempotent-safe when the extension is loaded. + op.execute("SELECT create_hypertable('market_data', 'timestamp', if_not_exists => TRUE)") + op.execute("SELECT create_hypertable('portfolio_snapshots', 'timestamp', if_not_exists => TRUE)") + op.execute("SELECT create_hypertable('strategy_metrics', 'timestamp', if_not_exists => TRUE)") + + +def downgrade() -> None: + """Drop all tables in reverse dependency order.""" + op.drop_table("strategy_metrics") + op.drop_table("portfolio_snapshots") + op.drop_table("market_data") + op.drop_table("user_credentials") + op.drop_table("users") + op.drop_table("learning_adjustments") + op.drop_table("trade_outcomes") + op.drop_table("article_sentiments") + op.drop_table("articles") + op.drop_table("strategy_weight_history") + op.drop_table("positions") + op.drop_table("trades") + op.drop_table("signals") + op.drop_table("strategies") + + # Drop enums + sa.Enum(name="signaldirection").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="tradeside").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="tradestatus").drop(op.get_bind(), checkfirst=True) diff --git a/shared/db.py b/shared/db.py new file mode 100644 index 0000000..574d183 --- /dev/null +++ b/shared/db.py @@ -0,0 +1,22 @@ +"""SQLAlchemy async engine and session factory.""" + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from shared.config import BaseConfig + + +def create_db(config: BaseConfig) -> tuple: + """Create an async engine and session factory from the given config. + + Returns a ``(engine, session_factory)`` tuple. + """ + engine = create_async_engine( + config.database_url, + echo=config.log_level == "DEBUG", + ) + session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + return engine, session_factory diff --git a/shared/models/__init__.py b/shared/models/__init__.py new file mode 100644 index 0000000..d461e61 --- /dev/null +++ b/shared/models/__init__.py @@ -0,0 +1,44 @@ +"""Shared SQLAlchemy models — import all models here so Alembic can discover them.""" + +from shared.models.base import Base, TimestampMixin +from shared.models.trading import ( + Signal, + SignalDirection, + Strategy, + StrategyWeightHistory, + Trade, + TradeSide, + TradeStatus, + Position, +) +from shared.models.news import Article, ArticleSentiment +from shared.models.learning import LearningAdjustment, TradeOutcome +from shared.models.auth import User, UserCredential +from shared.models.timeseries import MarketData, PortfolioSnapshot, StrategyMetric + +__all__ = [ + "Base", + "TimestampMixin", + # Trading + "Strategy", + "Signal", + "SignalDirection", + "Trade", + "TradeSide", + "TradeStatus", + "Position", + "StrategyWeightHistory", + # News + "Article", + "ArticleSentiment", + # Learning + "TradeOutcome", + "LearningAdjustment", + # Auth + "User", + "UserCredential", + # Timeseries + "MarketData", + "PortfolioSnapshot", + "StrategyMetric", +] diff --git a/shared/models/__pycache__/__init__.cpython-314.pyc b/shared/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf70a3b8ba01fa38cfe914fa2b808fc5ba3d4f53 GIT binary patch literal 1064 zcmZvaOK;Oa5XaZf%Xu`fCVhaQmN-SIF`@`j2_c2{u#sBY#znnhZJceg)Ul)8NlQ3Y zd;m_|_yl|$j_jop9JnHSsKkj~CsApIrTjdz&dmPj@vyj%L+WwweP(|W2>sB`{4r<7 z8N zP{buD#eT9~#ucdGDpX@X)n3FksNp))V?W(q!ppFXS6~IN!s;4oqK>s^z@1L!0cvE& z2TGJXAe{DW_h6?L_yane*u$tt17UwZ|75e_DB@s~Aef&HD5tiF>{dXBUFO?93GE&e zesoH?4Jy9bFlF*35mXir*^mk#!_h8#$HIms(=O|WB#@=)dz*3U1B*gwxxfj~{zTU2 z))8g>0bnK|;uD!VvJhw58-pJWsaF45P%ev_TdFlWngor6%LSdT#k(GVbm*Xg4 zksFec7(^h8bLG1fIP+gM7P+22S1W;dS|h2E(y%nL8hUkjUL&JX&{)tYX%sce8WoMI z#-c_|p>bDcJg*n|o+qm!9%FMlyJlDQ|N3@WzxfqZFI5Zv(G$yy1^h?Z?c^an6W2oJ z(#_<-tie<5x{BNJWDJArqU6fWcs(j5o>6hDTwSbMM9=$zE>%_}Jx7m7IH&AH?MRpK z_bhaLwY<)(!1ZnfPSX#_Se>0k`~*0q!XbS%(sgqD7%Q6vt=n_1f`goR6jnNt&e4Vw%{{8VaeYSu#+T$7l&W5*_>uFy`VC^Yz~ylMa{5Y9GJ8;I_%g{sueLnHIkWU)8GIpEa2_t5F_0snS{Ph;FL1%B68eb#pc$aB*@ndobvs!a{CE?-X5!KRobV4s_7E?==Q~Eo)SyKA%@8~ixY_&PfH^4C$P}6OJpTb9Mez2L*MvDCMaG45_!rKu z9Uus8{DylPMRLF+3%j7Y7Vq8C=4oz|y~F;Eo?9ZtrbtotHMcgGVLk~XK|24hC^+}% zMIOLyn`%ir&BI0Asu*fj!77iUxEm*zhYF2TY$bdYmjjxC+El`#*J6~5hHf!9hSE%# zmQ=>>qHd-NkToer)gqkxk$jn@lF-uz z-`iz6�zZDRmZ-XhfY!Kc9c$xu;XsoC+&Sx#^ZtAqkU}Di14)YMNyR?@m#89A`>4 zyTY&r>rF6!en{RUb&0gZF0oImm#Qm)?mF@Kq`IF7LOo1koeSfi%rA-K^$0qmq`muU z`xW+O&q~|lOXAsj480wsZTnU6`@WHt?a7sRYDt{@qZ9o1VSKVkPAA%UKyl=RwFDIf z)@mELE?DJxTQyKl@Je(6oD2Ew0BEC+h7i#Lm3zjf=a2VPH~R67Fl_C2+@>t_3n zJ-3T@CYO81Ynj#E>048`N549EC%wG;Z0#h(>^ppCW_j;q?bSyp-ur|;wLaTFqw66w zZ$el1|AekB$jbrBWM2bRlI-6CvM|D`(GK|q;97@a#6evYd>eR+Ilfj|f~zUmh>Yjj zV4RQ~Za}ZyfnLOcUWXiIodCUUatt!Jl@7q>c`IhE(v*=CG=`eR!CZ7w+DhuhDIyTSZ3;eLab|CwuMT0DLhuHjfQOr2E)L!NdJ>}_Lt1ShcN4epO<7ei|HiFo$HLLivkTdkNWaT7 z2VZTZqvz_*%LkUkH|p&!x#C?5_T|WuI9A83t8XLVur7y|#546Sbo7!?e4*o$;U#hO zk3Mj?S-A(6a5~XNhv7*_&?M2M&^(Q1Gpy({=yOno+odm{aiH}gmhg(C2hd>J(1U0k zhM}f(FPeR5(rBIq)6Ap+bfR&zc^?XEOxpD?Od3k9^Xlr@wTnOW?!7$;z#5WjCjdr6 zsl^L73pd{VF8#gy`tsl#wNtADBiHA@JGMM9RvTOG-*f%MZSBr$%l&6+V-Vi6>t=4b zd$e}+5o)x~E?gDB->)deGN9MxcF;O+s77H$z6VlJm=ZoZbS3?OVCg|m RR5&VpHhpd4A;FR}{NMMUtTzAv literal 0 HcmV?d00001 diff --git a/shared/models/__pycache__/base.cpython-314.pyc b/shared/models/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..949ba8cacce6bd0cc22bd519c4364d9d61c83ec8 GIT binary patch literal 1650 zcmbVM-*4PR5T3PtKHnvGmmtzq4N)ALf)2=CD^8?{3JA)>1<|N+N?xjD&#|4xs(+;G zy(GP-6CmJ~R;Wn)1^hQ4fz+#Gt-|XwvN*Qpi zPJiovtpGfw&B-bZm4S;&A1sit2^L#sZ6?_a>B_RwR;Ak3q?YZ~^-`~~w))Xx;Ouze1Jm`Kz!tXj_dIjU zPCV0&T+@leFpkX7Kk%cZQD>>)+R~GL=+%|fSVH|a>Z!Kfjhwog)|O6#e1I`h{ieOU z>$zzq%--CIgKii(c^bK7!_D9lDt*`h$*^{1vm{lENzGDZ!BR0>E!8Mw@71*(TX^pC z`EJL;w1dD5W7i9k#&@t%*HeS@$PPWu(=zAi=?0{aa(=&S2SZPd^KD-wGVmiWiqT!< z+>ISnCOA(dLgH{+O5tyHrI&CnsEGqgJH6BVIPpZ%-1G0rSZvv%=SNKk6KG0dyMAE2)`h-X!!JyAMPO|Bwk%5(CBO zaHw5-su^PZ#B~lz9j&wm-=nb4f?1R51&EPOQrjrZ!iJ7SQ7m;~2>wR#)>!uCIWY^Z z;oi~~PN-A*Ki~gn{KCQsUCZn6CbN*Pfgwk%q8wcZS;^^|R*7ubkZ|`V5wf?m=(>s7 z={UkeCc4<9PA8*nr_k@O{mpdl*YtIu%pKZGv$fR+bSWS%X#gT`4H9x?I z2kMHrfGDI!9OW?`!t8{!8tRn!z(R3@^qPvA(0@yV|aei}26KrQG;MPTfxdTP@O`kytl z<;0}Y*|A&D@X1%#+Ef#|IA&9`%k3>YU#H z@W_C+{kaMcf}QSP=U#7DE?3xV~5cC6{dfKD?h{H53qQwtE}|p Rd&hvUXQLxb9{`Fh*565ohPMC! literal 0 HcmV?d00001 diff --git a/shared/models/__pycache__/learning.cpython-314.pyc b/shared/models/__pycache__/learning.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..65f72f8fc87f05a3dae0f794667ff4c2964b3584 GIT binary patch literal 3637 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 literal 0 HcmV?d00001 diff --git a/shared/models/__pycache__/news.cpython-314.pyc b/shared/models/__pycache__/news.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bf5f3d304a0c4caedeebc6cb43398dde0f60f7c GIT binary patch literal 3623 zcmb_fT}&L;6}~e&Gdt`b%d)V>*s$1;urcg92FJg3h(qu~V&aYgDUI-G*cr^2VHV%H z+Zao&R&Av=kxmW_P_HH_1ar z;_R7o&i$El?)kpUsaP~b;8BkM$$U9T$XEDMf25l7^pBvFi9#5;MHFt5V;ubQlYE|M zd@tGplYzXzguKYayu_q@kOlKHlRcj>*^&>jkf)22;e3Qe@=+Ge$5_m>Nt3PlIE&}o zSX(dYAbms$ridcXx6Q{#1%C%D;n`cj9y(xeSGbcT6TW|T+W5dpYqp+t44auNAkr&E z-LRaC={G1d3zjidS8moLveOyPm3567I3gpu@;K--pu6IPRn(XpnkZ7kylua2thv%n zMos&+E6f=87z`9j$5&-!xPF zg%Qp3ws{e_m$AlPcvx9(>^3pq4lA@Mq529eIY&k~C7dFb%sP|^>zpGjr9`3jF*jH% zuq$hRrJb2=&JAjgS}K|PHt&X4skx%jHFX)v7}SkLtz@wYh>`7*WoZkRf#(3v!2P0a zK&_;bZQd;zGzHm#iTqh1KDMA0metkbY6(hfI0ejPEt>>SDrWoXD`2_ImB}2r#h?ET zAv#!(k)Lu)T#nr2-p4=gM(tcfeB&89+EB?QUg2JX*IInwkdOZkU*J1@LX%HW#3AVS z`g3RnsX6kHFWBTeyfeAUCn_z?Bb#;jz7R%Y@BRzVpv#-OD>=myEf{W~L@ifjCbNub zH?mqGC5mnWKr8$mn7MQ9!7;~cRzMq?Q4C>^5ZZ5MTUC`)#)6NwDVseaX zIsn7)LXqXNFtP-;4~le|QNwU@+HKh4u(mjQe&NDvr*qDdMh!jZV^hus)XJ`|xe-;> zY`e(dRH>?)tbIzh*&2myIDZE5-`|p7k*Y*mT0dmJU;A)vPwuJ`UX-dq!g19IiMDTy ztiQc0j8~&*h>=iyBe34RD_pF$q9INqiH(!%mv)8A)iyLFNPE{OM>fYl?cGac{HfZ} zeuT88Hty}k)4r_>Z9N#<{_xbUFjwtGLq7(wFOFsRQn|hO>0RNyZwA2r6gEn>$#mw3 zE32xlt>AZsR29CvWT6gUe7B@oHBYOmE}GO~mT4Py5!_G%hK=d_DN>YIHaZBJD`FW( zX)jtO6loNAnrR=36DY75=}8p*CyOc4oU^qqyfS?TZ&0Q(`FpabeEpxtGW&Am!AwOyx+U!B zf4%dUJI@0AajAS2s@OfaEp0iE!jFy0(ed)ceyV?SX6LO+>SFoL{jQfb*^W`^x?Fy1 zf9S%#9RIk#BKL0f!{}kD{0@xnf3;y90BgTgo&sxMc6)J0d35JV@{1d@mGqn!Iy%OE zzE&A|uRQfF7Ua>g--lPEOdyy=79+mvB3nA5^V55jg^2Y zl1+ZTi79-JiYYh>8xha4#o6kxSnCVQ@OwY%(%_j~qPJ0vMB!O`{fN?r~#-RvZ zZlcBu>T{Txp>5!y?I7-lYi=iN>JzjROc~xM6w$PG~j`3S`KVKZXoAJ#V3_Tqg$sCp2cXkhlcRS$WS2+;%UKiL&tuXZ5Hb|T7_9!3$> zQ)uw8*?Ab=6|Pllz#L0%Tw7Onh5R=N%zk&j3VWK)r05y=r6VXtQJh8b8j6F!O3$Iq zgH;i=F%%xazJVHETIf|2Z=x7S;UOw!n4U**0mVfWmq0WlB%T4UalB+bfVUb#4t@_p z4xFi@=OPbhX=b8tT>7mD$E4}&hwQn(DbwI>@W|D4DRrMmUf`4LoFv5M} zuBBOU`&e1?$;Hcumnbhs$Ukn+^PsG5YL-!8!0<(f-KGY({JTf2DG2Ty-rdA61*X$Y z_?LvNe@VzLz&{Q&0uGAg;!};6O{}h8XXGGPzU;H>75Wam0(=l>48*?!9LMdGOJ9-j zm*n`@WNeR&eMxfvAXj$Dm9NRjGbzFSjQgGavHpyp_Ux5b_z&;FFE`nL2x{I6{{zsj BCdU8( literal 0 HcmV?d00001 diff --git a/shared/models/__pycache__/timeseries.cpython-314.pyc b/shared/models/__pycache__/timeseries.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..964961bb80da1f3cf0071cd1775092a10fb8d2e4 GIT binary patch literal 4259 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 AVgLXD literal 0 HcmV?d00001 diff --git a/shared/models/__pycache__/trading.cpython-314.pyc b/shared/models/__pycache__/trading.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d0edd9631a8157383a9a68bb2c7d8acaf416fb9 GIT binary patch literal 9644 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 G^8W&=B9n{& literal 0 HcmV?d00001 diff --git a/shared/models/auth.py b/shared/models/auth.py new file mode 100644 index 0000000..a9c7f72 --- /dev/null +++ b/shared/models/auth.py @@ -0,0 +1,39 @@ +"""Authentication models: User, UserCredential.""" + +import uuid + +from sqlalchemy import ForeignKey, Integer, LargeBinary, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from shared.models.base import Base, TimestampMixin + + +class User(TimestampMixin, Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + display_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Relationships + credentials: Mapped[list["UserCredential"]] = relationship(back_populates="user") + + +class UserCredential(TimestampMixin, Base): + __tablename__ = "user_credentials" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id"), nullable=False + ) + credential_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False) + public_key: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + sign_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + # Relationships + user: Mapped[User] = relationship(back_populates="credentials") diff --git a/shared/models/base.py b/shared/models/base.py new file mode 100644 index 0000000..daefe3f --- /dev/null +++ b/shared/models/base.py @@ -0,0 +1,26 @@ +"""SQLAlchemy declarative base and common mixins.""" + +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + """Shared declarative base for all models.""" + + pass + + +class TimestampMixin: + """Adds ``created_at`` and ``updated_at`` columns with server defaults.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/shared/models/learning.py b/shared/models/learning.py new file mode 100644 index 0000000..ca9436d --- /dev/null +++ b/shared/models/learning.py @@ -0,0 +1,51 @@ +"""Learning domain models: TradeOutcome, LearningAdjustment.""" + +import uuid +from datetime import timedelta + +from sqlalchemy import Boolean, Float, ForeignKey, Interval, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from shared.models.base import Base, TimestampMixin + + +class TradeOutcome(TimestampMixin, Base): + __tablename__ = "trade_outcomes" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + trade_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("trades.id"), unique=True, nullable=False + ) + hold_duration: Mapped[timedelta | None] = mapped_column(Interval, nullable=True) + realized_pnl: Mapped[float] = mapped_column(Float, nullable=False) + roi_pct: Mapped[float] = mapped_column(Float, nullable=False) + was_profitable: Mapped[bool] = mapped_column(Boolean, nullable=False) + + # Relationships + trade: Mapped["Trade"] = relationship("Trade", back_populates="outcome") + + +class LearningAdjustment(TimestampMixin, Base): + __tablename__ = "learning_adjustments" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + strategy_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("strategies.id"), nullable=False + ) + old_weight: Mapped[float] = mapped_column(Float, nullable=False) + new_weight: Mapped[float] = mapped_column(Float, nullable=False) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + reward_signal: Mapped[float] = mapped_column(Float, nullable=False) + + # Relationships + strategy: Mapped["Strategy"] = relationship("Strategy") + + +# Avoid circular imports — reference by string in relationship() +from shared.models.trading import Trade as Trade # noqa: E402, F401 +from shared.models.trading import Strategy as Strategy # noqa: E402, F401 diff --git a/shared/models/news.py b/shared/models/news.py new file mode 100644 index 0000000..5b2caa1 --- /dev/null +++ b/shared/models/news.py @@ -0,0 +1,49 @@ +"""News and sentiment models: Article, ArticleSentiment.""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from shared.models.base import Base, TimestampMixin + + +class Article(TimestampMixin, Base): + __tablename__ = "articles" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + source: Mapped[str] = mapped_column(String(100), nullable=False) + url: Mapped[str] = mapped_column(Text, nullable=False) + title: Mapped[str] = mapped_column(Text, nullable=False) + published_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + fetched_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + content_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + + # Relationships + sentiments: Mapped[list["ArticleSentiment"]] = relationship(back_populates="article") + + +class ArticleSentiment(TimestampMixin, Base): + __tablename__ = "article_sentiments" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + article_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("articles.id"), nullable=False + ) + ticker: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + score: Mapped[float] = mapped_column(Float, nullable=False) + confidence: Mapped[float] = mapped_column(Float, nullable=False) + model_used: Mapped[str] = mapped_column(String(50), nullable=False) + + # Relationships + article: Mapped[Article] = relationship(back_populates="sentiments") diff --git a/shared/models/timeseries.py b/shared/models/timeseries.py new file mode 100644 index 0000000..15653be --- /dev/null +++ b/shared/models/timeseries.py @@ -0,0 +1,57 @@ +"""TimescaleDB hypertable models: MarketData, PortfolioSnapshot, StrategyMetric.""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from shared.models.base import Base + + +class MarketData(Base): + """OHLCV bars — intended as a TimescaleDB hypertable partitioned by timestamp.""" + + __tablename__ = "market_data" + + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), primary_key=True + ) + ticker: Mapped[str] = mapped_column(String(20), primary_key=True) + open: Mapped[float] = mapped_column(Float, nullable=False) + high: Mapped[float] = mapped_column(Float, nullable=False) + low: Mapped[float] = mapped_column(Float, nullable=False) + close: Mapped[float] = mapped_column(Float, nullable=False) + volume: Mapped[float] = mapped_column(Float, nullable=False) + + +class PortfolioSnapshot(Base): + """Periodic portfolio value snapshots — TimescaleDB hypertable.""" + + __tablename__ = "portfolio_snapshots" + + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), primary_key=True + ) + total_value: Mapped[float] = mapped_column(Float, nullable=False) + cash: Mapped[float] = mapped_column(Float, nullable=False) + positions_value: Mapped[float] = mapped_column(Float, nullable=False) + daily_pnl: Mapped[float] = mapped_column(Float, nullable=False) + + +class StrategyMetric(Base): + """Per-strategy performance over time — TimescaleDB hypertable.""" + + __tablename__ = "strategy_metrics" + + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), primary_key=True + ) + strategy_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("strategies.id"), primary_key=True + ) + win_rate: Mapped[float] = mapped_column(Float, nullable=False) + total_pnl: Mapped[float] = mapped_column(Float, nullable=False) + trade_count: Mapped[int] = mapped_column(Integer, nullable=False) + sharpe_ratio: Mapped[float | None] = mapped_column(Float, nullable=True) diff --git a/shared/models/trading.py b/shared/models/trading.py new file mode 100644 index 0000000..0e6eeb5 --- /dev/null +++ b/shared/models/trading.py @@ -0,0 +1,137 @@ +"""Trading domain models: Strategy, Signal, Trade, Position, StrategyWeightHistory.""" + +import enum +import uuid + +from sqlalchemy import Boolean, Float, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import JSON, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from shared.models.base import Base, TimestampMixin + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + +class TradeSide(str, enum.Enum): + BUY = "BUY" + SELL = "SELL" + + +class TradeStatus(str, enum.Enum): + PENDING = "PENDING" + FILLED = "FILLED" + CANCELLED = "CANCELLED" + REJECTED = "REJECTED" + + +class SignalDirection(str, enum.Enum): + LONG = "LONG" + SHORT = "SHORT" + NEUTRAL = "NEUTRAL" + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +class Strategy(TimestampMixin, Base): + __tablename__ = "strategies" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + current_weight: Mapped[float] = mapped_column(Float, nullable=False, default=0.333) + active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # Relationships + trades: Mapped[list["Trade"]] = relationship(back_populates="strategy") + signals: Mapped[list["Signal"]] = relationship(back_populates="strategy", foreign_keys="Signal.strategy_id", viewonly=True) + weight_history: Mapped[list["StrategyWeightHistory"]] = relationship(back_populates="strategy") + + +class Signal(TimestampMixin, Base): + __tablename__ = "signals" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + ticker: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + direction: Mapped[SignalDirection] = mapped_column(nullable=False) + strength: Mapped[float] = mapped_column(Float, nullable=False) + strategy_sources: Mapped[dict | None] = mapped_column(JSON, nullable=True) + sentiment_score: Mapped[float | None] = mapped_column(Float, nullable=True) + acted_on: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + strategy_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("strategies.id"), nullable=True + ) + + # Relationships + strategy: Mapped[Strategy | None] = relationship(back_populates="signals", foreign_keys=[strategy_id]) + trades: Mapped[list["Trade"]] = relationship(back_populates="signal") + + +class Trade(TimestampMixin, Base): + __tablename__ = "trades" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + ticker: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + side: Mapped[TradeSide] = mapped_column(nullable=False) + qty: Mapped[float] = mapped_column(Float, nullable=False) + price: Mapped[float] = mapped_column(Float, nullable=False) + timestamp: Mapped[str | None] = mapped_column(String, nullable=True) + strategy_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("strategies.id"), nullable=True + ) + signal_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("signals.id"), nullable=True + ) + status: Mapped[TradeStatus] = mapped_column(nullable=False, default=TradeStatus.PENDING) + pnl: Mapped[float | None] = mapped_column(Float, nullable=True) + + # Relationships + strategy: Mapped[Strategy | None] = relationship(back_populates="trades") + signal: Mapped[Signal | None] = relationship(back_populates="trades") + outcome: Mapped["TradeOutcome | None"] = relationship( + "TradeOutcome", back_populates="trade", uselist=False + ) + + +class Position(TimestampMixin, Base): + __tablename__ = "positions" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + ticker: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + qty: Mapped[float] = mapped_column(Float, nullable=False) + avg_entry: Mapped[float] = mapped_column(Float, nullable=False) + unrealized_pnl: Mapped[float | None] = mapped_column(Float, nullable=True) + stop_loss: Mapped[float | None] = mapped_column(Float, nullable=True) + take_profit: Mapped[float | None] = mapped_column(Float, nullable=True) + + +class StrategyWeightHistory(TimestampMixin, Base): + __tablename__ = "strategy_weight_history" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + strategy_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("strategies.id"), nullable=False + ) + old_weight: Mapped[float] = mapped_column(Float, nullable=False) + new_weight: Mapped[float] = mapped_column(Float, nullable=False) + reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # Relationships + strategy: Mapped[Strategy] = relationship(back_populates="weight_history") + + +# Avoid circular import — TradeOutcome is defined in learning.py +from shared.models.learning import TradeOutcome # noqa: E402, F401 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..1f426a2 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,323 @@ +"""Tests for SQLAlchemy model instantiation, enums, and relationships.""" + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +from shared.models import ( + Base, + TimestampMixin, + # Trading + Strategy, + Signal, + SignalDirection, + Trade, + TradeSide, + TradeStatus, + Position, + StrategyWeightHistory, + # News + Article, + ArticleSentiment, + # Learning + TradeOutcome, + LearningAdjustment, + # Auth + User, + UserCredential, + # Timeseries + MarketData, + PortfolioSnapshot, + StrategyMetric, +) +from shared.db import create_db +from shared.config import BaseConfig + + +# --------------------------------------------------------------------------- +# Enum tests +# --------------------------------------------------------------------------- + +class TestEnums: + def test_trade_side_values(self) -> None: + assert TradeSide.BUY == "BUY" + assert TradeSide.SELL == "SELL" + assert set(TradeSide) == {TradeSide.BUY, TradeSide.SELL} + + def test_trade_status_values(self) -> None: + assert TradeStatus.PENDING == "PENDING" + assert TradeStatus.FILLED == "FILLED" + assert TradeStatus.CANCELLED == "CANCELLED" + assert TradeStatus.REJECTED == "REJECTED" + assert len(TradeStatus) == 4 + + def test_signal_direction_values(self) -> None: + assert SignalDirection.LONG == "LONG" + assert SignalDirection.SHORT == "SHORT" + assert SignalDirection.NEUTRAL == "NEUTRAL" + assert len(SignalDirection) == 3 + + +# --------------------------------------------------------------------------- +# Model instantiation tests +# --------------------------------------------------------------------------- + +class TestStrategy: + def test_create_strategy(self) -> None: + s = Strategy( + id=uuid.uuid4(), + name="momentum", + description="Trend-following strategy", + current_weight=0.5, + active=True, + ) + assert s.name == "momentum" + assert s.current_weight == 0.5 + assert s.active is True + + def test_strategy_defaults(self) -> None: + """Without a DB session, Python-level defaults are not applied by SQLAlchemy. + The column default is only used at INSERT time.""" + s = Strategy(name="test") + assert s.description is None + # Column-level default=True is applied by the database at INSERT time, + # so in-memory the attribute is None until the row is flushed/refreshed. + assert s.active is None or s.active is True + + +class TestSignal: + def test_create_signal(self) -> None: + sig = Signal( + id=uuid.uuid4(), + ticker="AAPL", + direction=SignalDirection.LONG, + strength=0.85, + strategy_sources={"momentum": 0.9}, + sentiment_score=0.7, + acted_on=False, + ) + assert sig.ticker == "AAPL" + assert sig.direction == SignalDirection.LONG + assert sig.strength == 0.85 + assert sig.acted_on is False + + +class TestTrade: + def test_create_trade(self) -> None: + t = Trade( + id=uuid.uuid4(), + ticker="TSLA", + side=TradeSide.BUY, + qty=10.0, + price=150.25, + status=TradeStatus.FILLED, + pnl=250.50, + ) + assert t.ticker == "TSLA" + assert t.side == TradeSide.BUY + assert t.qty == 10.0 + assert t.price == 150.25 + assert t.status == TradeStatus.FILLED + assert t.pnl == 250.50 + + +class TestPosition: + def test_create_position(self) -> None: + p = Position( + id=uuid.uuid4(), + ticker="GOOG", + qty=5.0, + avg_entry=2800.00, + unrealized_pnl=-50.0, + stop_loss=2750.0, + take_profit=3000.0, + ) + assert p.ticker == "GOOG" + assert p.qty == 5.0 + assert p.stop_loss == 2750.0 + assert p.take_profit == 3000.0 + + +class TestStrategyWeightHistory: + def test_create_weight_history(self) -> None: + sid = uuid.uuid4() + wh = StrategyWeightHistory( + id=uuid.uuid4(), + strategy_id=sid, + old_weight=0.33, + new_weight=0.40, + reason="Improved win rate", + ) + assert wh.strategy_id == sid + assert wh.old_weight == 0.33 + assert wh.new_weight == 0.40 + + +class TestArticle: + def test_create_article(self) -> None: + now = datetime.now(timezone.utc) + a = Article( + id=uuid.uuid4(), + source="reuters", + url="https://reuters.com/article/1", + title="Market Rally", + published_at=now, + fetched_at=now, + content_hash="abc123def456", + ) + assert a.source == "reuters" + assert a.content_hash == "abc123def456" + + +class TestArticleSentiment: + def test_create_sentiment(self) -> None: + asent = ArticleSentiment( + id=uuid.uuid4(), + article_id=uuid.uuid4(), + ticker="AAPL", + score=0.85, + confidence=0.92, + model_used="finbert", + ) + assert asent.score == 0.85 + assert asent.model_used == "finbert" + + +class TestTradeOutcome: + def test_create_outcome(self) -> None: + outcome = TradeOutcome( + id=uuid.uuid4(), + trade_id=uuid.uuid4(), + hold_duration=timedelta(hours=4, minutes=30), + realized_pnl=125.50, + roi_pct=2.5, + was_profitable=True, + ) + assert outcome.realized_pnl == 125.50 + assert outcome.was_profitable is True + assert outcome.hold_duration == timedelta(hours=4, minutes=30) + + +class TestLearningAdjustment: + def test_create_adjustment(self) -> None: + adj = LearningAdjustment( + id=uuid.uuid4(), + strategy_id=uuid.uuid4(), + old_weight=0.30, + new_weight=0.35, + reason="Positive reward signal", + reward_signal=0.72, + ) + assert adj.reward_signal == 0.72 + assert adj.reason == "Positive reward signal" + + +class TestUser: + def test_create_user(self) -> None: + u = User( + id=uuid.uuid4(), + username="trader1", + display_name="Top Trader", + ) + assert u.username == "trader1" + assert u.display_name == "Top Trader" + + +class TestUserCredential: + def test_create_credential(self) -> None: + cred = UserCredential( + id=uuid.uuid4(), + user_id=uuid.uuid4(), + credential_id="cred-abc-123", + public_key=b"\x04abcdef", + sign_count=5, + ) + assert cred.credential_id == "cred-abc-123" + assert cred.sign_count == 5 + assert cred.public_key == b"\x04abcdef" + + +class TestMarketData: + def test_create_market_data(self) -> None: + now = datetime.now(timezone.utc) + md = MarketData( + timestamp=now, + ticker="AAPL", + open=150.0, + high=155.0, + low=149.0, + close=153.0, + volume=1_000_000.0, + ) + assert md.ticker == "AAPL" + assert md.close == 153.0 + + +class TestPortfolioSnapshot: + def test_create_snapshot(self) -> None: + now = datetime.now(timezone.utc) + snap = PortfolioSnapshot( + timestamp=now, + total_value=100_000.0, + cash=25_000.0, + positions_value=75_000.0, + daily_pnl=1_200.0, + ) + assert snap.total_value == 100_000.0 + assert snap.daily_pnl == 1_200.0 + + +class TestStrategyMetric: + def test_create_metric(self) -> None: + now = datetime.now(timezone.utc) + sm = StrategyMetric( + timestamp=now, + strategy_id=uuid.uuid4(), + win_rate=0.65, + total_pnl=5_432.10, + trade_count=42, + sharpe_ratio=1.8, + ) + assert sm.win_rate == 0.65 + assert sm.trade_count == 42 + assert sm.sharpe_ratio == 1.8 + + +# --------------------------------------------------------------------------- +# Metadata / Base tests +# --------------------------------------------------------------------------- + +class TestMetadata: + def test_all_tables_registered(self) -> None: + table_names = set(Base.metadata.tables.keys()) + expected = { + "strategies", + "signals", + "trades", + "positions", + "strategy_weight_history", + "articles", + "article_sentiments", + "trade_outcomes", + "learning_adjustments", + "users", + "user_credentials", + "market_data", + "portfolio_snapshots", + "strategy_metrics", + } + assert expected.issubset(table_names) + + def test_timestamp_mixin_fields(self) -> None: + """TimestampMixin should contribute created_at and updated_at columns.""" + assert "created_at" in Strategy.__table__.columns + assert "updated_at" in Strategy.__table__.columns + + +class TestDbFactory: + def test_create_db_returns_engine_and_session(self) -> None: + config = BaseConfig() + engine, session_factory = create_db(config) + assert engine is not None + assert session_factory is not None