From 65f0d510ec05fcfe969673be184a9e1a41c319d1 Mon Sep 17 00:00:00 2001 From: Daniel Maddern Date: Mon, 17 Aug 2026 13:48:50 +0700 Subject: [PATCH] Add offline-first Trading Studio v0.1 --- artifex/settings.py | 1 + artifex/urls.py | 3 + control_plane/trading_studio/__init__.py | 0 control_plane/trading_studio/apps.py | 6 + .../trading_studio/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/tradingstudio_import_features.py | 20 + .../tradingstudio_import_hyperscalper.py | 17 + .../commands/tradingstudio_report.py | 20 + .../trading_studio/migrations/0001_initial.py | 400 ++++++++++++++++++ .../trading_studio/migrations/__init__.py | 0 control_plane/trading_studio/models.py | 340 +++++++++++++++ control_plane/trading_studio/profiles.py | 54 +++ control_plane/trading_studio/services.py | 189 +++++++++ control_plane/trading_studio/views.py | 20 + .../trading_program_understanding_20260817.md | 83 ++++ templates/control_plane/base.html | 1 + templates/control_plane/trading_studio.html | 9 + .../control_plane/trading_studio_project.html | 9 + tests/test_trading_studio_v01.py | 99 +++++ 20 files changed, 1271 insertions(+) create mode 100644 control_plane/trading_studio/__init__.py create mode 100644 control_plane/trading_studio/apps.py create mode 100644 control_plane/trading_studio/management/__init__.py create mode 100644 control_plane/trading_studio/management/commands/__init__.py create mode 100644 control_plane/trading_studio/management/commands/tradingstudio_import_features.py create mode 100644 control_plane/trading_studio/management/commands/tradingstudio_import_hyperscalper.py create mode 100644 control_plane/trading_studio/management/commands/tradingstudio_report.py create mode 100644 control_plane/trading_studio/migrations/0001_initial.py create mode 100644 control_plane/trading_studio/migrations/__init__.py create mode 100644 control_plane/trading_studio/models.py create mode 100644 control_plane/trading_studio/profiles.py create mode 100644 control_plane/trading_studio/services.py create mode 100644 control_plane/trading_studio/views.py create mode 100644 docs/trading_program_understanding_20260817.md create mode 100644 templates/control_plane/trading_studio.html create mode 100644 templates/control_plane/trading_studio_project.html create mode 100644 tests/test_trading_studio_v01.py diff --git a/artifex/settings.py b/artifex/settings.py index 59f9bcc..de60383 100644 --- a/artifex/settings.py +++ b/artifex/settings.py @@ -23,6 +23,7 @@ INSTALLED_APPS = [ "control_plane.resources", "control_plane.ventures", "control_plane.model_studio", + "control_plane.trading_studio", "control_plane.secrets", "control_plane.knowledge", "control_plane.verification", diff --git a/artifex/urls.py b/artifex/urls.py index b25cd70..f0624a5 100644 --- a/artifex/urls.py +++ b/artifex/urls.py @@ -5,6 +5,7 @@ from django.urls import path from control_plane.projects import views from control_plane.model_studio import views as model_studio_views +from control_plane.trading_studio import views as trading_studio_views urlpatterns = [ path("", views.dashboard, name="dashboard"), @@ -35,6 +36,8 @@ urlpatterns = [ path("resources/", views.resources, name="resources"), path("model-studio/", model_studio_views.model_studio, name="model_studio"), path("model-studio//", model_studio_views.model_studio_project, name="model_studio_project"), + path("trading-studio/", trading_studio_views.trading_studio, name="trading_studio"), + path("trading-studio//", trading_studio_views.trading_studio_project, name="trading_studio_project"), path("approvals/", views.approvals, name="approvals"), path("approvals//action/", views.approval_action, name="approval_action"), path("activity/", views.activity, name="activity"), diff --git a/control_plane/trading_studio/__init__.py b/control_plane/trading_studio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/control_plane/trading_studio/apps.py b/control_plane/trading_studio/apps.py new file mode 100644 index 0000000..799f0fc --- /dev/null +++ b/control_plane/trading_studio/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TradingStudioConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "control_plane.trading_studio" diff --git a/control_plane/trading_studio/management/__init__.py b/control_plane/trading_studio/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/control_plane/trading_studio/management/commands/__init__.py b/control_plane/trading_studio/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/control_plane/trading_studio/management/commands/tradingstudio_import_features.py b/control_plane/trading_studio/management/commands/tradingstudio_import_features.py new file mode 100644 index 0000000..ed78dd9 --- /dev/null +++ b/control_plane/trading_studio/management/commands/tradingstudio_import_features.py @@ -0,0 +1,20 @@ +import json + +from django.core.management.base import BaseCommand, CommandError + +from control_plane.trading_studio.models import TradingProject +from control_plane.trading_studio.services import TradingStudioService + + +class Command(BaseCommand): + help = "Import existing HyperScalper feature implementation metadata without trusting it." + + def add_arguments(self, parser): + parser.add_argument("--project", required=True) + + def handle(self, *args, **options): + project = TradingProject.objects.filter(slug=options["project"]).first() + if project is None: + raise CommandError("TradingProject not found.") + features = TradingStudioService().import_features(project) + self.stdout.write(json.dumps({"project": project.slug, "imported": len(features), "leakage_status": "UNKNOWN"})) diff --git a/control_plane/trading_studio/management/commands/tradingstudio_import_hyperscalper.py b/control_plane/trading_studio/management/commands/tradingstudio_import_hyperscalper.py new file mode 100644 index 0000000..f3ef588 --- /dev/null +++ b/control_plane/trading_studio/management/commands/tradingstudio_import_hyperscalper.py @@ -0,0 +1,17 @@ +import json + +from django.core.management.base import BaseCommand + +from control_plane.trading_studio.services import TradingStudioService + + +class Command(BaseCommand): + help = "Create the offline-only HyperScalper Trading Studio project." + + def add_arguments(self, parser): + parser.add_argument("--repository-path", required=True) + parser.add_argument("--slug", default="crypto-hyperscalper") + + def handle(self, *args, **options): + project = TradingStudioService().import_hyperscalper(repository_path=options["repository_path"], slug=options["slug"]) + self.stdout.write(json.dumps({"id": str(project.id), "slug": project.slug, "status": project.status, "live_execution_enabled": project.metadata["live_execution_enabled"]})) diff --git a/control_plane/trading_studio/management/commands/tradingstudio_report.py b/control_plane/trading_studio/management/commands/tradingstudio_report.py new file mode 100644 index 0000000..1887255 --- /dev/null +++ b/control_plane/trading_studio/management/commands/tradingstudio_report.py @@ -0,0 +1,20 @@ +import json + +from django.core.management.base import BaseCommand, CommandError + +from control_plane.trading_studio.models import TradingProject +from control_plane.trading_studio.services import TradingStudioService + + +class Command(BaseCommand): + help = "Create a canonical Trading Studio cohort report." + + def add_arguments(self, parser): + parser.add_argument("--project", required=True) + + def handle(self, *args, **options): + project = TradingProject.objects.filter(slug=options["project"]).first() + if project is None: + raise CommandError("TradingProject not found.") + report = TradingStudioService().report(project) + self.stdout.write(json.dumps({"report": str(report.id), "title": report.title, "content": report.content}, default=str)) diff --git a/control_plane/trading_studio/migrations/0001_initial.py b/control_plane/trading_studio/migrations/0001_initial.py new file mode 100644 index 0000000..bc132e5 --- /dev/null +++ b/control_plane/trading_studio/migrations/0001_initial.py @@ -0,0 +1,400 @@ +# Generated by Django 5.2.16 on 2026-08-17 06:44 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('projects', '0006_roadmap_scenario_lab_v1'), + ] + + operations = [ + migrations.CreateModel( + name='FeatureDefinition', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=160)), + ('family', models.CharField(blank=True, max_length=120)), + ('implementation_reference', models.TextField()), + ('code_hash', models.CharField(blank=True, max_length=128)), + ('lookback', models.JSONField(blank=True, default=dict)), + ('dependencies', models.JSONField(blank=True, default=list)), + ('normalization', models.JSONField(blank=True, default=dict)), + ('timeframe', models.CharField(blank=True, max_length=80)), + ('leakage_status', models.CharField(choices=[('CONFIRMED', 'Confirmed'), ('INFERRED', 'Inferred'), ('UNKNOWN', 'Unknown'), ('SAFE', 'Safe'), ('SUSPICIOUS', 'Suspicious'), ('BLOCKED', 'Blocked')], default='UNKNOWN', max_length=16)), + ('evidence', models.JSONField(blank=True, default=dict)), + ], + ), + migrations.CreateModel( + name='MarketDataset', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ('kind', models.CharField(choices=[('OHLCV', 'Ohlcv'), ('TRADES', 'Trades'), ('TICKS', 'Ticks'), ('ORDER_BOOK', 'Order Book'), ('FUNDING', 'Funding'), ('OPEN_INTEREST', 'Open Interest'), ('LIQUIDATIONS', 'Liquidations'), ('MARK_PRICE', 'Mark Price'), ('INDEX_PRICE', 'Index Price'), ('FILL_HISTORY', 'Fill History'), ('OTHER', 'Other')], max_length=32)), + ('venue', models.CharField(blank=True, max_length=120)), + ('symbol', models.CharField(blank=True, max_length=120)), + ('market_type', models.CharField(blank=True, max_length=80)), + ('metadata', models.JSONField(blank=True, default=dict)), + ], + ), + migrations.CreateModel( + name='Strategy', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ('strategy_class', models.CharField(blank=True, max_length=80)), + ('description', models.TextField(blank=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='StrategyExperiment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('hypothesis', models.TextField()), + ('market_rationale', models.TextField()), + ('expected_regime', models.JSONField(default=dict)), + ('controls', models.JSONField(default=dict)), + ('success_criteria', models.JSONField(default=dict)), + ('rejection_criteria', models.JSONField(default=dict)), + ('risk_assumptions', models.JSONField(default=dict)), + ('execution_assumptions', models.JSONField(default=dict)), + ('estimated_evaluation_cost', models.JSONField(default=dict)), + ('status', models.CharField(choices=[('PROPOSED', 'Proposed'), ('RUNNING', 'Running'), ('SUCCEEDED', 'Succeeded'), ('REJECTED', 'Rejected'), ('FAILED', 'Failed')], default='PROPOSED', max_length=32)), + ('conclusion', models.CharField(blank=True, choices=[('KILL', 'Kill'), ('REVISE', 'Revise'), ('ADVANCE', 'Advance'), ('PROMOTE', 'Promote'), ('DEMOTE', 'Demote'), ('REQUIRE_MORE_EVIDENCE', 'Require More Evidence')], max_length=32)), + ('failure_type', models.CharField(blank=True, choices=[('NO_ALPHA', 'No Alpha'), ('OVERFIT', 'Overfit'), ('LEAKAGE', 'Leakage'), ('FEE_DESTROYED', 'Fee Destroyed'), ('SLIPPAGE_DESTROYED', 'Slippage Destroyed'), ('LATENCY_DESTROYED', 'Latency Destroyed'), ('REGIME_FRAGILE', 'Regime Fragile'), ('PARAMETER_FRAGILE', 'Parameter Fragile'), ('INSUFFICIENT_TRADES', 'Insufficient Trades'), ('RISK_TOO_HIGH', 'Risk Too High'), ('TAIL_RISK', 'Tail Risk'), ('EXECUTION_FAILURE', 'Execution Failure'), ('DATA_FAILURE', 'Data Failure'), ('LIVE_DIVERGENCE', 'Live Divergence'), ('CAPACITY_LIMIT', 'Capacity Limit'), ('UNKNOWN', 'Unknown')], max_length=32)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='FeatureSetVersion', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=160)), + ('version', models.CharField(max_length=120)), + ('content_hash', models.CharField(max_length=128)), + ('immutable', models.BooleanField(default=False)), + ('feature_definitions', models.ManyToManyField(related_name='feature_sets', to='trading_studio.featuredefinition')), + ], + ), + migrations.CreateModel( + name='MarketDatasetVersion', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('version', models.CharField(max_length=120)), + ('reference', models.TextField()), + ('content_hash', models.CharField(max_length=128)), + ('processing_version', models.CharField(blank=True, max_length=120)), + ('start_at', models.DateTimeField(blank=True, null=True)), + ('end_at', models.DateTimeField(blank=True, null=True)), + ('resolution', models.CharField(blank=True, max_length=80)), + ('record_count', models.BigIntegerField(blank=True, null=True)), + ('fields', models.JSONField(blank=True, default=list)), + ('quality', models.JSONField(blank=True, default=dict)), + ('lookahead_risk_status', models.CharField(choices=[('CONFIRMED', 'Confirmed'), ('INFERRED', 'Inferred'), ('UNKNOWN', 'Unknown'), ('SAFE', 'Safe'), ('SUSPICIOUS', 'Suspicious'), ('BLOCKED', 'Blocked')], default='UNKNOWN', max_length=16)), + ('quality_status', models.CharField(choices=[('CONFIRMED', 'Confirmed'), ('INFERRED', 'Inferred'), ('UNKNOWN', 'Unknown'), ('SAFE', 'Safe'), ('SUSPICIOUS', 'Suspicious'), ('BLOCKED', 'Blocked')], default='UNKNOWN', max_length=16)), + ('temporal_splits', models.JSONField(blank=True, default=dict)), + ('immutable', models.BooleanField(default=False)), + ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='trading_studio.marketdataset')), + ], + ), + migrations.CreateModel( + name='BacktestRun', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('split', models.CharField(choices=[('DISCOVERY', 'Discovery'), ('TRAIN', 'Train'), ('VALIDATION', 'Validation'), ('HOLDOUT', 'Holdout'), ('FORWARD', 'Forward'), ('LIVE', 'Live')], max_length=32)), + ('execution_model_version', models.CharField(max_length=120)), + ('configuration', models.JSONField(default=dict)), + ('status', models.CharField(choices=[('QUEUED', 'Queued'), ('RUNNING', 'Running'), ('SUCCEEDED', 'Succeeded'), ('FAILED', 'Failed'), ('INVALID', 'Invalid')], default='QUEUED', max_length=16)), + ('metrics', models.JSONField(default=dict)), + ('artifact_reference', models.TextField(blank=True)), + ('failure_details', models.TextField(blank=True)), + ('dataset_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='backtest_runs', to='trading_studio.marketdatasetversion')), + ('experiment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='backtests', to='trading_studio.strategyexperiment')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='StrategyVersion', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('version', models.CharField(max_length=120)), + ('genome', models.JSONField(default=dict)), + ('fingerprint', models.CharField(max_length=128)), + ('code_reference', models.TextField(blank=True)), + ('stage', models.CharField(choices=[('HYPOTHESIS', 'Hypothesis'), ('BACKTEST', 'Backtest'), ('WALK_FORWARD', 'Walk Forward'), ('ROBUSTNESS', 'Robustness'), ('HOLDOUT', 'Holdout'), ('SHADOW', 'Shadow'), ('MICRO_LIVE', 'Micro Live'), ('PROVEN', 'Proven'), ('CHAMPION', 'Champion'), ('KILLED', 'Killed')], default='HYPOTHESIS', max_length=32)), + ('holdout_exposure_count', models.PositiveIntegerField(default=0)), + ('immutable', models.BooleanField(default=False)), + ('feature_set', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='strategy_versions', to='trading_studio.featuresetversion')), + ('parent_version', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='trading_studio.strategyversion')), + ('strategy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='trading_studio.strategy')), + ], + ), + migrations.AddField( + model_name='strategyexperiment', + name='strategy_version', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='experiments', to='trading_studio.strategyversion'), + ), + migrations.CreateModel( + name='StrategyEvaluation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('stage', models.CharField(choices=[('HYPOTHESIS', 'Hypothesis'), ('BACKTEST', 'Backtest'), ('WALK_FORWARD', 'Walk Forward'), ('ROBUSTNESS', 'Robustness'), ('HOLDOUT', 'Holdout'), ('SHADOW', 'Shadow'), ('MICRO_LIVE', 'Micro Live'), ('PROVEN', 'Proven'), ('CHAMPION', 'Champion'), ('KILLED', 'Killed')], max_length=32)), + ('verdict', models.CharField(choices=[('KILL', 'Kill'), ('REVISE', 'Revise'), ('ADVANCE', 'Advance'), ('PROMOTE', 'Promote'), ('DEMOTE', 'Demote'), ('REQUIRE_MORE_EVIDENCE', 'Require More Evidence')], max_length=32)), + ('metrics', models.JSONField(default=dict)), + ('evidence', models.JSONField(default=dict)), + ('failure_type', models.CharField(blank=True, choices=[('NO_ALPHA', 'No Alpha'), ('OVERFIT', 'Overfit'), ('LEAKAGE', 'Leakage'), ('FEE_DESTROYED', 'Fee Destroyed'), ('SLIPPAGE_DESTROYED', 'Slippage Destroyed'), ('LATENCY_DESTROYED', 'Latency Destroyed'), ('REGIME_FRAGILE', 'Regime Fragile'), ('PARAMETER_FRAGILE', 'Parameter Fragile'), ('INSUFFICIENT_TRADES', 'Insufficient Trades'), ('RISK_TOO_HIGH', 'Risk Too High'), ('TAIL_RISK', 'Tail Risk'), ('EXECUTION_FAILURE', 'Execution Failure'), ('DATA_FAILURE', 'Data Failure'), ('LIVE_DIVERGENCE', 'Live Divergence'), ('CAPACITY_LIMIT', 'Capacity Limit'), ('UNKNOWN', 'Unknown')], max_length=32)), + ('experiment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='evaluations', to='trading_studio.strategyexperiment')), + ('strategy_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='evaluations', to='trading_studio.strategyversion')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='StrategyCapitalAllocation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('tier', models.CharField(choices=[('RESEARCH', 'Research'), ('SHADOW', 'Shadow'), ('MICRO', 'Micro'), ('VALIDATED', 'Validated'), ('PROVEN', 'Proven'), ('CHAMPION', 'Champion')], max_length=32)), + ('amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('active', models.BooleanField(default=False)), + ('policy_snapshot', models.JSONField(default=dict)), + ('strategy_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='capital_allocations', to='trading_studio.strategyversion')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ShadowRun', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('status', models.CharField(choices=[('QUEUED', 'Queued'), ('RUNNING', 'Running'), ('SUCCEEDED', 'Succeeded'), ('FAILED', 'Failed'), ('INVALID', 'Invalid')], default='QUEUED', max_length=16)), + ('evidence', models.JSONField(default=dict)), + ('strategy_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shadow_runs', to='trading_studio.strategyversion')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LiveStrategyRun', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('status', models.CharField(choices=[('QUEUED', 'Queued'), ('RUNNING', 'Running'), ('SUCCEEDED', 'Succeeded'), ('FAILED', 'Failed'), ('INVALID', 'Invalid')], default='QUEUED', max_length=16)), + ('allocated_capital', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('safety_policy', models.JSONField(default=dict)), + ('reconciliation_status', models.CharField(default='NOT_STARTED', max_length=32)), + ('enabled', models.BooleanField(default=False)), + ('strategy_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='live_runs', to='trading_studio.strategyversion')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TradeRecord', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('source', models.CharField(default='IMPORTED', max_length=32)), + ('lifecycle', models.JSONField(default=dict)), + ('gross_pnl', models.FloatField(default=0)), + ('fees', models.FloatField(default=0)), + ('funding', models.FloatField(default=0)), + ('slippage', models.FloatField(default=0)), + ('other_execution_cost', models.FloatField(default=0)), + ('net_pnl', models.FloatField(default=0)), + ('live_run', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='trades', to='trading_studio.livestrategyrun')), + ('strategy_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='trade_records', to='trading_studio.strategyversion')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TradingCohort', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ('status', models.CharField(choices=[('HYPOTHESIS', 'Hypothesis'), ('BACKTEST', 'Backtest'), ('WALK_FORWARD', 'Walk Forward'), ('ROBUSTNESS', 'Robustness'), ('HOLDOUT', 'Holdout'), ('SHADOW', 'Shadow'), ('MICRO_LIVE', 'Micro Live'), ('PROVEN', 'Proven'), ('CHAMPION', 'Champion'), ('KILLED', 'Killed')], default='HYPOTHESIS', max_length=32)), + ('policy_snapshot', models.JSONField(default=dict)), + ('dataset_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cohorts', to='trading_studio.marketdatasetversion')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='strategyexperiment', + name='cohort', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='experiments', to='trading_studio.tradingcohort'), + ), + migrations.CreateModel( + name='TradingProject', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ('slug', models.SlugField(max_length=120, unique=True)), + ('goal', models.TextField()), + ('repository_path', models.TextField(blank=True)), + ('working_directory', models.TextField(blank=True)), + ('profile_name', models.CharField(default='crypto_hyperliquid', max_length=120)), + ('existing_system_profile', models.CharField(default='hyperscalper', max_length=120)), + ('status', models.CharField(choices=[('ARCHAEOLOGY', 'Archaeology'), ('DATA_VALIDATION', 'Data Validation'), ('BACKTEST_AUDIT', 'Backtest Audit'), ('OFFLINE_ONLY', 'Offline Only'), ('SHADOW_READY', 'Shadow Ready'), ('PAUSED', 'Paused')], default='ARCHAEOLOGY', max_length=32)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('current_champion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='champion_for_projects', to='trading_studio.strategyversion')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='trading_projects', to='projects.project')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='tradingcohort', + name='trading_project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cohorts', to='trading_studio.tradingproject'), + ), + migrations.CreateModel( + name='StrategyPromotionDecision', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('decision', models.CharField(choices=[('KILL', 'Kill'), ('REVISE', 'Revise'), ('ADVANCE', 'Advance'), ('PROMOTE', 'Promote'), ('DEMOTE', 'Demote'), ('REQUIRE_MORE_EVIDENCE', 'Require More Evidence')], max_length=32)), + ('policy_snapshot', models.JSONField(default=dict)), + ('evidence', models.JSONField(default=dict)), + ('reason', models.TextField()), + ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='promotion_decisions', to='trading_studio.strategyversion')), + ('champion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='challenged_by', to='trading_studio.strategyversion')), + ('trading_project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='promotion_decisions', to='trading_studio.tradingproject')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='strategy', + name='trading_project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='strategies', to='trading_studio.tradingproject'), + ), + migrations.AddField( + model_name='marketdataset', + name='trading_project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='market_datasets', to='trading_studio.tradingproject'), + ), + migrations.AddField( + model_name='featuresetversion', + name='trading_project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_sets', to='trading_studio.tradingproject'), + ), + migrations.AddField( + model_name='featuredefinition', + name='trading_project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='trading_studio.tradingproject'), + ), + migrations.CreateModel( + name='TradingResearchReport', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('report_type', models.CharField(max_length=80)), + ('title', models.CharField(max_length=255)), + ('content', models.JSONField(default=dict)), + ('markdown', models.TextField(blank=True)), + ('evidence_status', models.CharField(choices=[('CONFIRMED', 'Confirmed'), ('INFERRED', 'Inferred'), ('UNKNOWN', 'Unknown'), ('SAFE', 'Safe'), ('SUSPICIOUS', 'Suspicious'), ('BLOCKED', 'Blocked')], default='UNKNOWN', max_length=16)), + ('trading_project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='research_reports', to='trading_studio.tradingproject')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='tradingcohort', + name='report', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cohorts', to='trading_studio.tradingresearchreport'), + ), + migrations.CreateModel( + name='WalkForwardRun', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('folds', models.JSONField(default=list)), + ('embargo_bars', models.PositiveIntegerField(default=0)), + ('status', models.CharField(choices=[('QUEUED', 'Queued'), ('RUNNING', 'Running'), ('SUCCEEDED', 'Succeeded'), ('FAILED', 'Failed'), ('INVALID', 'Invalid')], default='QUEUED', max_length=16)), + ('metrics', models.JSONField(default=dict)), + ('dataset_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='walk_forward_runs', to='trading_studio.marketdatasetversion')), + ('experiment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='walk_forwards', to='trading_studio.strategyexperiment')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddConstraint( + model_name='marketdatasetversion', + constraint=models.UniqueConstraint(fields=('dataset', 'version'), name='unique_trading_market_dataset_version'), + ), + migrations.AddConstraint( + model_name='strategyversion', + constraint=models.UniqueConstraint(fields=('strategy', 'version'), name='unique_trading_strategy_version'), + ), + migrations.AddConstraint( + model_name='strategyversion', + constraint=models.UniqueConstraint(fields=('strategy', 'fingerprint'), name='unique_trading_strategy_fingerprint'), + ), + migrations.AddConstraint( + model_name='marketdataset', + constraint=models.UniqueConstraint(fields=('trading_project', 'name'), name='unique_trading_market_dataset'), + ), + migrations.AddConstraint( + model_name='featuresetversion', + constraint=models.UniqueConstraint(fields=('trading_project', 'name', 'version'), name='unique_trading_feature_set_version'), + ), + migrations.AddConstraint( + model_name='featuredefinition', + constraint=models.UniqueConstraint(fields=('trading_project', 'name', 'code_hash'), name='unique_trading_feature_definition'), + ), + ] diff --git a/control_plane/trading_studio/migrations/__init__.py b/control_plane/trading_studio/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/control_plane/trading_studio/models.py b/control_plane/trading_studio/models.py new file mode 100644 index 0000000..a8907bd --- /dev/null +++ b/control_plane/trading_studio/models.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +from django.db import models + +from control_plane.common import TimestampedModel + + +class TradingProjectStatus(models.TextChoices): + ARCHAEOLOGY = "ARCHAEOLOGY" + DATA_VALIDATION = "DATA_VALIDATION" + BACKTEST_AUDIT = "BACKTEST_AUDIT" + OFFLINE_ONLY = "OFFLINE_ONLY" + SHADOW_READY = "SHADOW_READY" + PAUSED = "PAUSED" + + +class DataKind(models.TextChoices): + OHLCV = "OHLCV" + TRADES = "TRADES" + TICKS = "TICKS" + ORDER_BOOK = "ORDER_BOOK" + FUNDING = "FUNDING" + OPEN_INTEREST = "OPEN_INTEREST" + LIQUIDATIONS = "LIQUIDATIONS" + MARK_PRICE = "MARK_PRICE" + INDEX_PRICE = "INDEX_PRICE" + FILL_HISTORY = "FILL_HISTORY" + OTHER = "OTHER" + + +class EvidenceStatus(models.TextChoices): + CONFIRMED = "CONFIRMED" + INFERRED = "INFERRED" + UNKNOWN = "UNKNOWN" + SAFE = "SAFE" + SUSPICIOUS = "SUSPICIOUS" + BLOCKED = "BLOCKED" + + +class SplitKind(models.TextChoices): + DISCOVERY = "DISCOVERY" + TRAIN = "TRAIN" + VALIDATION = "VALIDATION" + HOLDOUT = "HOLDOUT" + FORWARD = "FORWARD" + LIVE = "LIVE" + + +class StrategyStage(models.TextChoices): + HYPOTHESIS = "HYPOTHESIS" + BACKTEST = "BACKTEST" + WALK_FORWARD = "WALK_FORWARD" + ROBUSTNESS = "ROBUSTNESS" + HOLDOUT = "HOLDOUT" + SHADOW = "SHADOW" + MICRO_LIVE = "MICRO_LIVE" + PROVEN = "PROVEN" + CHAMPION = "CHAMPION" + KILLED = "KILLED" + + +class ExperimentStatus(models.TextChoices): + PROPOSED = "PROPOSED" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + REJECTED = "REJECTED" + FAILED = "FAILED" + + +class RunStatus(models.TextChoices): + QUEUED = "QUEUED" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + INVALID = "INVALID" + + +class Decision(models.TextChoices): + KILL = "KILL" + REVISE = "REVISE" + ADVANCE = "ADVANCE" + PROMOTE = "PROMOTE" + DEMOTE = "DEMOTE" + REQUIRE_MORE_EVIDENCE = "REQUIRE_MORE_EVIDENCE" + + +class FailureType(models.TextChoices): + NO_ALPHA = "NO_ALPHA" + OVERFIT = "OVERFIT" + LEAKAGE = "LEAKAGE" + FEE_DESTROYED = "FEE_DESTROYED" + SLIPPAGE_DESTROYED = "SLIPPAGE_DESTROYED" + LATENCY_DESTROYED = "LATENCY_DESTROYED" + REGIME_FRAGILE = "REGIME_FRAGILE" + PARAMETER_FRAGILE = "PARAMETER_FRAGILE" + INSUFFICIENT_TRADES = "INSUFFICIENT_TRADES" + RISK_TOO_HIGH = "RISK_TOO_HIGH" + TAIL_RISK = "TAIL_RISK" + EXECUTION_FAILURE = "EXECUTION_FAILURE" + DATA_FAILURE = "DATA_FAILURE" + LIVE_DIVERGENCE = "LIVE_DIVERGENCE" + CAPACITY_LIMIT = "CAPACITY_LIMIT" + UNKNOWN = "UNKNOWN" + + +class AllocationTier(models.TextChoices): + RESEARCH = "RESEARCH" + SHADOW = "SHADOW" + MICRO = "MICRO" + VALIDATED = "VALIDATED" + PROVEN = "PROVEN" + CHAMPION = "CHAMPION" + + +class TradingProject(TimestampedModel): + project = models.ForeignKey("projects.Project", on_delete=models.PROTECT, related_name="trading_projects") + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=120, unique=True) + goal = models.TextField() + repository_path = models.TextField(blank=True) + working_directory = models.TextField(blank=True) + profile_name = models.CharField(max_length=120, default="crypto_hyperliquid") + existing_system_profile = models.CharField(max_length=120, default="hyperscalper") + status = models.CharField(max_length=32, choices=TradingProjectStatus.choices, default=TradingProjectStatus.ARCHAEOLOGY) + current_champion = models.ForeignKey("StrategyVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="champion_for_projects") + metadata = models.JSONField(default=dict, blank=True) + + +class MarketDataset(TimestampedModel): + trading_project = models.ForeignKey(TradingProject, on_delete=models.CASCADE, related_name="market_datasets") + name = models.CharField(max_length=200) + kind = models.CharField(max_length=32, choices=DataKind.choices) + venue = models.CharField(max_length=120, blank=True) + symbol = models.CharField(max_length=120, blank=True) + market_type = models.CharField(max_length=80, blank=True) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + constraints = [models.UniqueConstraint(fields=["trading_project", "name"], name="unique_trading_market_dataset")] + + +class MarketDatasetVersion(TimestampedModel): + dataset = models.ForeignKey(MarketDataset, on_delete=models.CASCADE, related_name="versions") + version = models.CharField(max_length=120) + reference = models.TextField() + content_hash = models.CharField(max_length=128) + processing_version = models.CharField(max_length=120, blank=True) + start_at = models.DateTimeField(null=True, blank=True) + end_at = models.DateTimeField(null=True, blank=True) + resolution = models.CharField(max_length=80, blank=True) + record_count = models.BigIntegerField(null=True, blank=True) + fields = models.JSONField(default=list, blank=True) + quality = models.JSONField(default=dict, blank=True) + lookahead_risk_status = models.CharField(max_length=16, choices=EvidenceStatus.choices, default=EvidenceStatus.UNKNOWN) + quality_status = models.CharField(max_length=16, choices=EvidenceStatus.choices, default=EvidenceStatus.UNKNOWN) + temporal_splits = models.JSONField(default=dict, blank=True) + immutable = models.BooleanField(default=False) + + class Meta: + constraints = [models.UniqueConstraint(fields=["dataset", "version"], name="unique_trading_market_dataset_version")] + + def save(self, *args, **kwargs): + if self.pk and self.immutable: + original = type(self).objects.get(pk=self.pk) + fields = ["version", "reference", "content_hash", "processing_version", "start_at", "end_at", "resolution", "record_count", "fields", "quality", "lookahead_risk_status", "temporal_splits"] + if any(getattr(self, field) != getattr(original, field) for field in fields): + raise ValueError("MarketDatasetVersion is immutable after research use.") + super().save(*args, **kwargs) + + +class FeatureDefinition(TimestampedModel): + trading_project = models.ForeignKey(TradingProject, on_delete=models.CASCADE, related_name="features") + name = models.CharField(max_length=160) + family = models.CharField(max_length=120, blank=True) + implementation_reference = models.TextField() + code_hash = models.CharField(max_length=128, blank=True) + lookback = models.JSONField(default=dict, blank=True) + dependencies = models.JSONField(default=list, blank=True) + normalization = models.JSONField(default=dict, blank=True) + timeframe = models.CharField(max_length=80, blank=True) + leakage_status = models.CharField(max_length=16, choices=EvidenceStatus.choices, default=EvidenceStatus.UNKNOWN) + evidence = models.JSONField(default=dict, blank=True) + + class Meta: + constraints = [models.UniqueConstraint(fields=["trading_project", "name", "code_hash"], name="unique_trading_feature_definition")] + + +class FeatureSetVersion(TimestampedModel): + trading_project = models.ForeignKey(TradingProject, on_delete=models.CASCADE, related_name="feature_sets") + name = models.CharField(max_length=160) + version = models.CharField(max_length=120) + feature_definitions = models.ManyToManyField(FeatureDefinition, related_name="feature_sets") + content_hash = models.CharField(max_length=128) + immutable = models.BooleanField(default=False) + + class Meta: + constraints = [models.UniqueConstraint(fields=["trading_project", "name", "version"], name="unique_trading_feature_set_version")] + + +class Strategy(TimestampedModel): + trading_project = models.ForeignKey(TradingProject, on_delete=models.CASCADE, related_name="strategies") + name = models.CharField(max_length=200) + strategy_class = models.CharField(max_length=80, blank=True) + description = models.TextField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + + +class StrategyVersion(TimestampedModel): + strategy = models.ForeignKey(Strategy, on_delete=models.CASCADE, related_name="versions") + version = models.CharField(max_length=120) + parent_version = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="children") + feature_set = models.ForeignKey(FeatureSetVersion, on_delete=models.PROTECT, null=True, blank=True, related_name="strategy_versions") + genome = models.JSONField(default=dict) + fingerprint = models.CharField(max_length=128) + code_reference = models.TextField(blank=True) + stage = models.CharField(max_length=32, choices=StrategyStage.choices, default=StrategyStage.HYPOTHESIS) + holdout_exposure_count = models.PositiveIntegerField(default=0) + immutable = models.BooleanField(default=False) + + class Meta: + constraints = [models.UniqueConstraint(fields=["strategy", "version"], name="unique_trading_strategy_version"), models.UniqueConstraint(fields=["strategy", "fingerprint"], name="unique_trading_strategy_fingerprint")] + + def save(self, *args, **kwargs): + if self.pk and self.immutable: + original = type(self).objects.get(pk=self.pk) + if any(getattr(self, field) != getattr(original, field) for field in ["version", "parent_version_id", "feature_set_id", "genome", "fingerprint", "code_reference"]): + raise ValueError("Live or evaluated StrategyVersion is immutable; create a child version.") + super().save(*args, **kwargs) + + +class TradingCohort(TimestampedModel): + trading_project = models.ForeignKey(TradingProject, on_delete=models.CASCADE, related_name="cohorts") + name = models.CharField(max_length=200) + status = models.CharField(max_length=32, choices=StrategyStage.choices, default=StrategyStage.HYPOTHESIS) + policy_snapshot = models.JSONField(default=dict) + dataset_version = models.ForeignKey(MarketDatasetVersion, on_delete=models.PROTECT, related_name="cohorts") + report = models.ForeignKey("TradingResearchReport", on_delete=models.SET_NULL, null=True, blank=True, related_name="cohorts") + + +class StrategyExperiment(TimestampedModel): + cohort = models.ForeignKey(TradingCohort, on_delete=models.CASCADE, related_name="experiments") + strategy_version = models.ForeignKey(StrategyVersion, on_delete=models.PROTECT, related_name="experiments") + hypothesis = models.TextField() + market_rationale = models.TextField() + expected_regime = models.JSONField(default=dict) + controls = models.JSONField(default=dict) + success_criteria = models.JSONField(default=dict) + rejection_criteria = models.JSONField(default=dict) + risk_assumptions = models.JSONField(default=dict) + execution_assumptions = models.JSONField(default=dict) + estimated_evaluation_cost = models.JSONField(default=dict) + status = models.CharField(max_length=32, choices=ExperimentStatus.choices, default=ExperimentStatus.PROPOSED) + conclusion = models.CharField(max_length=32, choices=Decision.choices, blank=True) + failure_type = models.CharField(max_length=32, choices=FailureType.choices, blank=True) + + +class BacktestRun(TimestampedModel): + experiment = models.ForeignKey(StrategyExperiment, on_delete=models.PROTECT, related_name="backtests") + dataset_version = models.ForeignKey(MarketDatasetVersion, on_delete=models.PROTECT, related_name="backtest_runs") + split = models.CharField(max_length=32, choices=SplitKind.choices) + execution_model_version = models.CharField(max_length=120) + configuration = models.JSONField(default=dict) + status = models.CharField(max_length=16, choices=RunStatus.choices, default=RunStatus.QUEUED) + metrics = models.JSONField(default=dict) + artifact_reference = models.TextField(blank=True) + failure_details = models.TextField(blank=True) + + +class WalkForwardRun(TimestampedModel): + experiment = models.ForeignKey(StrategyExperiment, on_delete=models.PROTECT, related_name="walk_forwards") + dataset_version = models.ForeignKey(MarketDatasetVersion, on_delete=models.PROTECT, related_name="walk_forward_runs") + folds = models.JSONField(default=list) + embargo_bars = models.PositiveIntegerField(default=0) + status = models.CharField(max_length=16, choices=RunStatus.choices, default=RunStatus.QUEUED) + metrics = models.JSONField(default=dict) + + +class ShadowRun(TimestampedModel): + strategy_version = models.ForeignKey(StrategyVersion, on_delete=models.PROTECT, related_name="shadow_runs") + status = models.CharField(max_length=16, choices=RunStatus.choices, default=RunStatus.QUEUED) + evidence = models.JSONField(default=dict) + + +class LiveStrategyRun(TimestampedModel): + strategy_version = models.ForeignKey(StrategyVersion, on_delete=models.PROTECT, related_name="live_runs") + status = models.CharField(max_length=16, choices=RunStatus.choices, default=RunStatus.QUEUED) + allocated_capital = models.DecimalField(max_digits=12, decimal_places=2, default=0) + safety_policy = models.JSONField(default=dict) + reconciliation_status = models.CharField(max_length=32, default="NOT_STARTED") + enabled = models.BooleanField(default=False) + + +class TradeRecord(TimestampedModel): + strategy_version = models.ForeignKey(StrategyVersion, on_delete=models.PROTECT, related_name="trade_records") + live_run = models.ForeignKey(LiveStrategyRun, on_delete=models.SET_NULL, null=True, blank=True, related_name="trades") + source = models.CharField(max_length=32, default="IMPORTED") + lifecycle = models.JSONField(default=dict) + gross_pnl = models.FloatField(default=0) + fees = models.FloatField(default=0) + funding = models.FloatField(default=0) + slippage = models.FloatField(default=0) + other_execution_cost = models.FloatField(default=0) + net_pnl = models.FloatField(default=0) + + +class StrategyEvaluation(TimestampedModel): + strategy_version = models.ForeignKey(StrategyVersion, on_delete=models.PROTECT, related_name="evaluations") + experiment = models.ForeignKey(StrategyExperiment, on_delete=models.PROTECT, related_name="evaluations") + stage = models.CharField(max_length=32, choices=StrategyStage.choices) + verdict = models.CharField(max_length=32, choices=Decision.choices) + metrics = models.JSONField(default=dict) + evidence = models.JSONField(default=dict) + failure_type = models.CharField(max_length=32, choices=FailureType.choices, blank=True) + + +class StrategyPromotionDecision(TimestampedModel): + trading_project = models.ForeignKey(TradingProject, on_delete=models.CASCADE, related_name="promotion_decisions") + candidate = models.ForeignKey(StrategyVersion, on_delete=models.PROTECT, related_name="promotion_decisions") + champion = models.ForeignKey(StrategyVersion, on_delete=models.SET_NULL, null=True, blank=True, related_name="challenged_by") + decision = models.CharField(max_length=32, choices=Decision.choices) + policy_snapshot = models.JSONField(default=dict) + evidence = models.JSONField(default=dict) + reason = models.TextField() + + +class StrategyCapitalAllocation(TimestampedModel): + strategy_version = models.ForeignKey(StrategyVersion, on_delete=models.PROTECT, related_name="capital_allocations") + tier = models.CharField(max_length=32, choices=AllocationTier.choices) + amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + active = models.BooleanField(default=False) + policy_snapshot = models.JSONField(default=dict) + + +class TradingResearchReport(TimestampedModel): + trading_project = models.ForeignKey(TradingProject, on_delete=models.CASCADE, related_name="research_reports") + report_type = models.CharField(max_length=80) + title = models.CharField(max_length=255) + content = models.JSONField(default=dict) + markdown = models.TextField(blank=True) + evidence_status = models.CharField(max_length=16, choices=EvidenceStatus.choices, default=EvidenceStatus.UNKNOWN) diff --git a/control_plane/trading_studio/profiles.py b/control_plane/trading_studio/profiles.py new file mode 100644 index 0000000..ac7ed01 --- /dev/null +++ b/control_plane/trading_studio/profiles.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Protocol + + +class TradingProjectProfile(Protocol): + name: str + + def archaeology(self, repository_path: str) -> dict: ... + + +class CryptoTradingProfile: + name = "crypto" + + def archaeology(self, repository_path: str) -> dict: + root = Path(repository_path) + return { + "profile": self.name, + "repository": str(root), + "status": "UNKNOWN" if not root.exists() else "CONFIRMED", + "datasets": [], + "findings": [], + } + + +class HyperliquidProfile(CryptoTradingProfile): + name = "crypto_hyperliquid" + + +class ExistingSystemProfile(Protocol): + name: str + + def feature_inventory(self, repository_path: str) -> list[dict]: ... + + +class HyperScalperProfile: + name = "hyperscalper" + + def feature_inventory(self, repository_path: str) -> list[dict]: + root = Path(repository_path) + candidates = [ + root / "src" / "hyperscalper" / "fast_engine.py", + root / "src" / "hyperscalper" / "indicators_depth_nb.py", + root / "src" / "hyperscalper" / "indicator_library.py", + ] + rows = [] + for path in candidates: + if not path.exists(): + continue + digest = hashlib.sha256(path.read_bytes()).hexdigest() + rows.append({"name": path.stem, "family": "HYPERSCALPER_IMPORT", "implementation_reference": str(path), "code_hash": digest}) + return rows diff --git a/control_plane/trading_studio/services.py b/control_plane/trading_studio/services.py new file mode 100644 index 0000000..f77a8dc --- /dev/null +++ b/control_plane/trading_studio/services.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime +from decimal import Decimal +from typing import Any + +from django.db import transaction + +from control_plane.events.bus import EventBus +from control_plane.projects.models import Project, ProjectStatus +from control_plane.trading_studio.models import ( + AllocationTier, BacktestRun, DataKind, EvidenceStatus, ExperimentStatus, + FailureType, FeatureDefinition, FeatureSetVersion, LiveStrategyRun, + MarketDataset, MarketDatasetVersion, RunStatus, ShadowRun, SplitKind, + Strategy, StrategyCapitalAllocation, StrategyEvaluation, StrategyExperiment, + StrategyPromotionDecision, StrategyStage, StrategyVersion, TradingCohort, + TradingProject, TradingProjectStatus, TradingResearchReport, +) +from control_plane.trading_studio.profiles import CryptoTradingProfile, ExistingSystemProfile, HyperScalperProfile, TradingProjectProfile + + +class TradingStudioService: + """Canonical, offline-first trading research service. + + This service intentionally has no exchange adapter. A future live stage must + be separately authorized and supplied with a deterministic risk executor. + """ + + def __init__(self, *, profile: TradingProjectProfile | None = None, existing_system: ExistingSystemProfile | None = None, bus: EventBus | None = None) -> None: + self.profile = profile or CryptoTradingProfile() + self.existing_system = existing_system or HyperScalperProfile() + self.bus = bus or EventBus() + + def import_hyperscalper(self, *, repository_path: str, slug: str = "crypto-hyperscalper") -> TradingProject: + project, _ = Project.objects.get_or_create( + name="Crypto Trading Studio", project_type="TRADING", + defaults={"goal": "Falsify trading hypotheses before any capital allocation.", "repository_path": repository_path, "status": ProjectStatus.ARCHAEOLOGY}, + ) + trading_project, _ = TradingProject.objects.update_or_create( + slug=slug, + defaults={ + "project": project, "name": "Crypto / Hyperliquid Strategy Lab", + "goal": "Prefer no strategy to an overfit strategy.", + "repository_path": repository_path, "profile_name": self.profile.name, + "existing_system_profile": self.existing_system.name, + "status": TradingProjectStatus.ARCHAEOLOGY, + "metadata": {"live_execution_enabled": False, "maximum_automatic_live_capital": "0", "safety_note": "Trading Studio V0.1 is offline-only. No exchange calls are implemented."}, + }, + ) + self._event("TRADING_PROJECT_IMPORTED", trading_project, {"repository_path": repository_path}) + return trading_project + + def register_market_dataset(self, trading_project: TradingProject, *, name: str, kind: str, version: str, reference: str, content_hash: str, fields: list[str], record_count: int, start_at: datetime | None, end_at: datetime | None, resolution: str, quality: dict[str, Any], temporal_splits: dict[str, Any]) -> MarketDatasetVersion: + if kind not in DataKind.values: + raise ValueError("Unknown market data kind.") + self._validate_temporal_splits(temporal_splits) + dataset, _ = MarketDataset.objects.get_or_create(trading_project=trading_project, name=name, defaults={"kind": kind}) + if dataset.kind != kind: + raise ValueError("Market dataset kind cannot change after registration.") + return MarketDatasetVersion.objects.create( + dataset=dataset, version=version, reference=reference, content_hash=content_hash, + fields=fields, record_count=record_count, start_at=start_at, end_at=end_at, + resolution=resolution, quality=quality, temporal_splits=temporal_splits, + lookahead_risk_status=EvidenceStatus.UNKNOWN, quality_status=EvidenceStatus.SUSPICIOUS, + ) + + def import_features(self, trading_project: TradingProject) -> list[FeatureDefinition]: + features = [] + for item in self.existing_system.feature_inventory(trading_project.repository_path): + feature, _ = FeatureDefinition.objects.get_or_create( + trading_project=trading_project, name=item["name"], code_hash=item["code_hash"], + defaults={"family": item["family"], "implementation_reference": item["implementation_reference"], "leakage_status": EvidenceStatus.UNKNOWN}, + ) + features.append(feature) + return features + + def create_feature_set(self, trading_project: TradingProject, *, name: str, version: str, features: list[FeatureDefinition]) -> FeatureSetVersion: + if any(feature.trading_project_id != trading_project.id for feature in features): + raise ValueError("Feature sets cannot cross trading projects.") + content_hash = self._hash({"features": sorted(str(feature.id) for feature in features)}) + feature_set = FeatureSetVersion.objects.create(trading_project=trading_project, name=name, version=version, content_hash=content_hash) + feature_set.feature_definitions.set(features) + return feature_set + + def create_strategy_version(self, trading_project: TradingProject, *, name: str, genome: dict[str, Any], feature_set: FeatureSetVersion | None = None, parent_version: StrategyVersion | None = None) -> StrategyVersion: + if self._contains_prohibited_sizing(genome): + raise ValueError("Martingale, loss chasing, and uncapped averaging are prohibited.") + if feature_set and feature_set.feature_definitions.filter(leakage_status=EvidenceStatus.BLOCKED).exists(): + raise ValueError("Strategies using BLOCKED features cannot qualify.") + fingerprint = self._hash({"genome": genome, "feature_set": str(feature_set.id) if feature_set else ""}) + strategy, _ = Strategy.objects.get_or_create(trading_project=trading_project, name=name) + return StrategyVersion.objects.create(strategy=strategy, version=f"v{strategy.versions.count() + 1}", parent_version=parent_version, feature_set=feature_set, genome=genome, fingerprint=fingerprint) + + def propose_experiment(self, cohort: TradingCohort, strategy_version: StrategyVersion, contract: dict[str, Any]) -> StrategyExperiment: + required = ["hypothesis", "market_rationale", "expected_regime", "controls", "success_criteria", "rejection_criteria", "risk_assumptions", "execution_assumptions", "estimated_evaluation_cost"] + missing = [name for name in required if contract.get(name) in (None, "", {}, [])] + if missing: + raise ValueError("Incomplete strategy experiment contract: " + ", ".join(missing)) + if strategy_version.strategy.trading_project_id != cohort.trading_project_id: + raise ValueError("Strategy and cohort must belong to the same TradingProject.") + if strategy_version.holdout_exposure_count and contract["controls"].get("uses_holdout_for_design"): + raise ValueError("Consumed holdout cannot be used for adaptive strategy design.") + return StrategyExperiment.objects.create(cohort=cohort, strategy_version=strategy_version, **{key: contract[key] for key in required}) + + @transaction.atomic + def record_backtest(self, experiment: StrategyExperiment, *, split: str, execution_model_version: str, metrics: dict[str, Any], configuration: dict[str, Any], artifact_reference: str = "") -> BacktestRun: + if split not in SplitKind.values: + raise ValueError("Unknown temporal split.") + if configuration.get("same_bar_close_execution"): + raise ValueError("Same-bar close execution is prohibited.") + required = ["gross_pnl", "fees", "funding", "slippage", "other_execution_cost", "net_pnl", "trade_count"] + missing = [name for name in required if name not in metrics] + if missing: + raise ValueError("Backtest metrics missing: " + ", ".join(missing)) + computed_net = float(metrics["gross_pnl"]) - float(metrics["fees"]) - float(metrics["funding"]) - float(metrics["slippage"]) - float(metrics["other_execution_cost"]) + if abs(computed_net - float(metrics["net_pnl"])) > 1e-9: + raise ValueError("Net PnL must equal gross PnL minus all execution costs.") + run = BacktestRun.objects.create(experiment=experiment, dataset_version=experiment.cohort.dataset_version, split=split, execution_model_version=execution_model_version, configuration=configuration, metrics=metrics, artifact_reference=artifact_reference, status=RunStatus.SUCCEEDED) + if split == SplitKind.HOLDOUT: + version = experiment.strategy_version + version.holdout_exposure_count += 1 + version.immutable = True + version.save(update_fields=["holdout_exposure_count", "immutable", "updated_at"]) + return run + + def judge_backtest(self, experiment: StrategyExperiment, backtest: BacktestRun, *, policy: dict[str, Any]) -> StrategyEvaluation: + metrics = backtest.metrics + failure = "" + verdict = "ADVANCE" + if float(metrics["net_pnl"]) < 0 <= float(metrics["gross_pnl"]): + failure, verdict = FailureType.FEE_DESTROYED, "KILL" + elif float(metrics["net_pnl"]) < 0: + failure, verdict = FailureType.NO_ALPHA, "KILL" + elif int(metrics["trade_count"]) < int(policy.get("minimum_trade_count", 0)): + failure, verdict = FailureType.INSUFFICIENT_TRADES, "REQUIRE_MORE_EVIDENCE" + elif float(metrics.get("pnl_concentration_top_trade", 0)) > float(policy.get("maximum_top_trade_concentration", 1)): + failure, verdict = FailureType.OVERFIT, "KILL" + evaluation = StrategyEvaluation.objects.create(strategy_version=experiment.strategy_version, experiment=experiment, stage=StrategyStage.BACKTEST, verdict=verdict, metrics=metrics, evidence={"policy": policy, "execution_model": backtest.execution_model_version}, failure_type=failure) + if verdict == "KILL": + experiment.status, experiment.conclusion, experiment.failure_type = ExperimentStatus.REJECTED, verdict, failure + experiment.strategy_version.stage = StrategyStage.KILLED + experiment.strategy_version.save(update_fields=["stage", "updated_at"]) + else: + experiment.status, experiment.conclusion = ExperimentStatus.SUCCEEDED, verdict + experiment.save(update_fields=["status", "conclusion", "failure_type", "updated_at"]) + return evaluation + + def request_shadow(self, strategy_version: StrategyVersion) -> ShadowRun: + if strategy_version.stage not in {StrategyStage.HOLDOUT, StrategyStage.SHADOW}: + raise ValueError("Shadow requires a holdout-qualified immutable strategy version.") + return ShadowRun.objects.create(strategy_version=strategy_version, status=RunStatus.QUEUED, evidence={"mode": "PREPARED_ONLY", "live_execution_enabled": False}) + + def request_micro_live(self, strategy_version: StrategyVersion, amount: Decimal) -> LiveStrategyRun: + raise ValueError("Trading Studio V0.1 is offline-only. Explicit human authorization and a future deterministic execution/risk adapter are required before micro-live.") + + def report(self, trading_project: TradingProject, *, title: str = "Trading cohort report") -> TradingResearchReport: + experiments = list(StrategyExperiment.objects.filter(cohort__trading_project=trading_project).select_related("strategy_version")) + payload = {"project": trading_project.slug, "status": trading_project.status, "offline_only": not trading_project.metadata.get("live_execution_enabled", False), "experiments": [{"strategy": item.strategy_version.strategy.name, "version": item.strategy_version.version, "status": item.status, "conclusion": item.conclusion, "failure_type": item.failure_type} for item in experiments], "counts": {"proposed": sum(item.status == ExperimentStatus.PROPOSED for item in experiments), "rejected": sum(item.status == ExperimentStatus.REJECTED for item in experiments), "survivors": sum(item.status == ExperimentStatus.SUCCEEDED for item in experiments)}} + return TradingResearchReport.objects.create(trading_project=trading_project, report_type="COHORT", title=title, content=payload, markdown="# TRADING COHORT REPORT\n\n" + json.dumps(payload, indent=2), evidence_status=EvidenceStatus.CONFIRMED) + + def _validate_temporal_splits(self, splits: dict[str, Any]) -> None: + if splits.get("split_method") == "random": + raise ValueError("Random time-series splits are prohibited.") + ordered = [splits.get(kind) for kind in ["DISCOVERY", "TRAIN", "VALIDATION", "HOLDOUT", "FORWARD", "LIVE"] if splits.get(kind)] + previous_end = None + for item in ordered: + start, end = item.get("start"), item.get("end") + if not start or not end: + raise ValueError("Temporal split boundaries are required.") + if start >= end or previous_end and start < previous_end: + raise ValueError("Temporal splits must be chronological and non-overlapping.") + previous_end = end + + def _contains_prohibited_sizing(self, value: Any) -> bool: + prohibited = ("martingale", "double_after_loss", "averaging_down", "loss_chasing", "negative_progression", "uncapped_grid") + if isinstance(value, dict): + return any(self._contains_prohibited_sizing(key) or self._contains_prohibited_sizing(item) for key, item in value.items()) + if isinstance(value, list): + return any(self._contains_prohibited_sizing(item) for item in value) + return any(term in str(value).lower() for term in prohibited) + + def _event(self, event_type: str, trading_project: TradingProject, payload: dict[str, Any]) -> None: + self.bus.publish(project=trading_project.project, event_type=event_type, payload=payload) + + @staticmethod + def _hash(value: Any) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest() diff --git a/control_plane/trading_studio/views.py b/control_plane/trading_studio/views.py new file mode 100644 index 0000000..04bc14c --- /dev/null +++ b/control_plane/trading_studio/views.py @@ -0,0 +1,20 @@ +from django.shortcuts import get_object_or_404, render + +from control_plane.trading_studio.models import TradingProject + + +def trading_studio(request): + projects = TradingProject.objects.select_related("project", "current_champion").order_by("name") + return render(request, "control_plane/trading_studio.html", {"trading_projects": projects}) + + +def trading_studio_project(request, project_id): + trading_project = get_object_or_404(TradingProject.objects.select_related("project", "current_champion"), id=project_id) + return render(request, "control_plane/trading_studio_project.html", { + "trading_project": trading_project, + "datasets": trading_project.market_datasets.prefetch_related("versions").all(), + "features": trading_project.features.order_by("name"), + "strategies": trading_project.strategies.prefetch_related("versions").all(), + "cohorts": trading_project.cohorts.prefetch_related("experiments").all(), + "reports": trading_project.research_reports.order_by("-created_at")[:10], + }) diff --git a/docs/trading_program_understanding_20260817.md b/docs/trading_program_understanding_20260817.md new file mode 100644 index 0000000..894b7c9 --- /dev/null +++ b/docs/trading_program_understanding_20260817.md @@ -0,0 +1,83 @@ +# Trading Program Understanding + +## Scope + +This report records read-only archaeology of `S:\PycharmProjects\SquadWatch`, +the Spark replay workspace, and the Pi service state. No exchange action, +credential access, service restart, or database mutation occurred. + +## Current Decision + +**CONFIRMED: no existing strategy qualifies for Shadow Mode from the latest +Spark evidence.** The latest 45-day selection/final comparison selected 50 +families and recorded `survivor_count: 0`. This is a successful falsification +result, not a reason to loosen the gate. + +## Data + +| Status | Evidence | +| --- | --- | +| CONFIRMED | Spark snapshot `/home/daniel/hyperscalper-replay/binance_btcusd_2m_455d.csv` has 327,599 BTCUSD 2-minute OHLCV rows with `timestamp,open,high,low,close,volume`. Its observed range is Unix `1743840960` through `1783152720`. | +| CONFIRMED | Spark has a chronologically separated 90-day OOS snapshot and 45-day selection/final partitions: `binance_btcusd_2m_oos_90d.csv`, `binance_btcusd_2m_oos_select_45d.csv`, and `binance_btcusd_2m_oos_final_45d.csv`. The selection/final boundary is adjacent, not shuffled. | +| CONFIRMED | The newest discovered Spark HyperScalper artifacts were written 2026-08-08. They are not current market data on 2026-08-17. | +| CONFIRMED | Pi logs were updated 2026-08-17, but `hyperscalper-live.service` and `hyperscalper-risk50-paper.service` were inactive during the read-only check. | +| UNKNOWN | Current live exchange positions, fills, and account equity were not queried. Trading Studio V0.1 intentionally does not make exchange calls. | +| UNKNOWN | Funding, open interest, order book, tick, and liquidation datasets were not located in the verified Spark replay input. They must not be implied by OHLCV availability. | + +## HyperScalper Architecture + +| Status | Evidence | +| --- | --- | +| CONFIRMED | The source system is `src/hyperscalper` in SquadWatch. It contains indicator/search code, paper replay, feed, bot, executor, risk, and Postgres/SQLite journaling components. | +| CONFIRMED | Live topology documented in `docs/hyperscalper_pi_deployments.md` is feed -> paper/signal authority -> webhook-only live runner -> Hyperliquid executor. Documented initial live controls were single position, 1x leverage, and max notional 20. | +| CONFIRMED | The Pi documentation states a known accounting limitation: bot-managed exits may record local trigger price/PnL rather than the actual exchange close fill. Historical live PnL therefore needs reconciliation before it is treated as canonical. | +| INFERRED | Existing execution controls are useful source material but cannot be trusted as an Artifex execution adapter until restart recovery, idempotency, account-level risk persistence, and reconciliation are audited. | + +## Features And Leakage + +| Status | Evidence | +| --- | --- | +| CONFIRMED | `fast_engine.py`, `indicators_depth_nb.py`, and `indicator_library.py` provide a large multi-family feature universe. The implementation uses trend, oscillator, volatility, channel, pivot, time, and candle-derived signals. | +| CONFIRMED | The active paper-like replay computes signal at completed-bar close and enters at next-bar open with intrabar TP/SL handling. | +| BLOCKED | The high-throughput search kernel enters at same-bar close while the paper replay uses next-bar open. Search PnL is not execution-equivalent evidence and cannot qualify a strategy. | +| BLOCKED | Search volatility percentile calibration is derived from the full evaluated window in `fast_engine.py`; this leaks later observations into early threshold calibration. It must be fit on train only and frozen before validation/holdout. | +| SUSPICIOUS | Existing walk-forward code has no documented purge/embargo around position/cooldown boundaries. Future Artifex folds must embargo at least the maximum holding period plus cooldown. | + +## Existing Strategy Evidence + +| Status | Evidence | +| --- | --- | +| CONFIRMED | Spark report `/home/daniel/hyperscalper-replay/reports/oos_45d_selection_final_comparison.json` applied its stated selection rule to 50 candidates and reported zero final survivors. | +| CONFIRMED | The selection candidates exhibited strong prior-window PFs, while the displayed final-window results were negative with PF below one. This is direct evidence of regime/search fragility, not a live execution result. | +| CONFIRMED | `walkforward_14d_14d.json` states it used a read-only transaction and that selection used only fully completed training trades. It is useful historical evidence, but its thresholds and data partition policy must be revalidated by Artifex before reuse. | +| UNKNOWN | The local 14-row SQLite live journal is too small to establish a live alpha conclusion. The documented main 60-day paper database was not present in the inspected local workspace. | + +## Backtester Integrity Assessment + +**PARTIAL / NOT QUALIFIED FOR PROMOTION** + +- Reuse candidate: `src/hyperscalper/paper_replay.py` because its timing model is closer to intended paper/live semantics. +- Reject for qualification: `fast_engine.evaluate_combo()` and the close-only kernel until same-bar and full-window calibration issues are repaired. +- Required before a future cohort: immutable source hash, chronological discovery/train/validation/holdout split, train-only thresholds, purge/embargo, fixed cost version, and next-open/intrabar conservative replay. +- Required before Shadow: a fresh data snapshot after the 2026-08-08 Spark cutoff, a successful independent cohort, and an Artifex shadow runner that records intended order, observed market evolution, and simulated fill without exchange submission. + +## V0.1 Cohort Policy + +The first Artifex cohort is bounded to 20 pre-existing, fingerprint-deduplicated +strategy genomes. It must run the paper-like replay with frozen data/splits and +cost stress. The latest Spark report already proves that the current 50-candidate +selection did not survive its final partition. Artifex must preserve that failure +knowledge and must not promote any member of that family from those results. + +## Shadow Readiness + +**NOT READY.** No shadow command is authorized to start yet. Once a new current +market snapshot is registered, has passed quality validation, and an offline +cohort produces a holdout-qualified survivor, the future explicit command is: + +```powershell +python manage.py trading_shadow --project crypto-hyperscalper --strategy-version --dry-run +``` + +That command is intentionally not implemented in V0.1 because it would create a +false impression that shadow qualification is currently satisfied. diff --git a/templates/control_plane/base.html b/templates/control_plane/base.html index 3baa7d5..1560041 100644 --- a/templates/control_plane/base.html +++ b/templates/control_plane/base.html @@ -47,6 +47,7 @@ Dashboard Projects Model Studio + Trading Studio Agents Progeny Steward diff --git a/templates/control_plane/trading_studio.html b/templates/control_plane/trading_studio.html new file mode 100644 index 0000000..bca8a57 --- /dev/null +++ b/templates/control_plane/trading_studio.html @@ -0,0 +1,9 @@ +{% extends "control_plane/base.html" %} +{% block content %} + +
+{% for item in trading_projects %} +

{{ item.status }}

{{ item.name }}

{{ item.goal }}

Champion: {% if item.current_champion %}{{ item.current_champion.strategy.name }} {{ item.current_champion.version }}{% else %}None{% endif %}

+{% empty %}

No trading projects

Import an existing system before proposing strategies.

{% endfor %} +
+{% endblock %} diff --git a/templates/control_plane/trading_studio_project.html b/templates/control_plane/trading_studio_project.html new file mode 100644 index 0000000..29a3a40 --- /dev/null +++ b/templates/control_plane/trading_studio_project.html @@ -0,0 +1,9 @@ +{% extends "control_plane/base.html" %} +{% block content %} + +

Safety status

Live execution enabled: {{ trading_project.metadata.live_execution_enabled|yesno:"yes,no" }}

Automatic live capital: {{ trading_project.metadata.maximum_automatic_live_capital }}

+

Data

    {% for dataset in datasets %}
  • {{ dataset.name }} ({{ dataset.kind }}){% for version in dataset.versions.all %}: {{ version.version }} / {{ version.record_count }} records / {{ version.quality_status }}{% endfor %}
  • {% empty %}
  • No registered datasets.
  • {% endfor %}
+

Feature library

{{ features|length }} imported feature implementations.

    {% for feature in features %}
  • {{ feature.name }}: {{ feature.leakage_status }}
  • {% endfor %}
+

Strategies and cohorts

    {% for strategy in strategies %}
  • {{ strategy.name }}{% for version in strategy.versions.all %}: {{ version.version }} / {{ version.stage }}{% endfor %}
  • {% empty %}
  • No strategies registered.
  • {% endfor %}

Cohorts: {{ cohorts|length }}

+

Research reports

    {% for report in reports %}
  • {{ report.created_at }}: {{ report.title }} ({{ report.evidence_status }})
  • {% empty %}
  • No reports yet.
  • {% endfor %}
+{% endblock %} diff --git a/tests/test_trading_studio_v01.py b/tests/test_trading_studio_v01.py new file mode 100644 index 0000000..887ee52 --- /dev/null +++ b/tests/test_trading_studio_v01.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from control_plane.trading_studio.models import ( + EvidenceStatus, ExperimentStatus, FailureType, FeatureDefinition, StrategyStage, TradingCohort, +) +from control_plane.trading_studio.services import TradingStudioService + + +def splits(): + return { + "split_method": "chronological", + "DISCOVERY": {"start": "2026-01-01T00:00:00Z", "end": "2026-01-10T00:00:00Z"}, + "TRAIN": {"start": "2026-01-10T00:00:00Z", "end": "2026-01-20T00:00:00Z"}, + "VALIDATION": {"start": "2026-01-20T00:00:00Z", "end": "2026-01-25T00:00:00Z"}, + "HOLDOUT": {"start": "2026-01-25T00:00:00Z", "end": "2026-02-01T00:00:00Z"}, + } + + +def metrics(**overrides): + payload = {"gross_pnl": 10.0, "fees": 2.0, "funding": 1.0, "slippage": 1.0, "other_execution_cost": 0.0, "net_pnl": 6.0, "trade_count": 40, "pnl_concentration_top_trade": 0.1} + return {**payload, **overrides} + + +def contract(**overrides): + payload = {"hypothesis": "A frozen existing signal retains net edge.", "market_rationale": "Imported evidence only.", "expected_regime": {"trend": "unknown"}, "controls": {"same_execution_model": True}, "success_criteria": {"net_pnl": ">0"}, "rejection_criteria": {"net_pnl": "<0"}, "risk_assumptions": {"leverage": 1}, "execution_assumptions": {"entry": "next_bar_open"}, "estimated_evaluation_cost": {"cpu_seconds": 1}} + return {**payload, **overrides} + + +def ready(): + service = TradingStudioService() + project = service.import_hyperscalper(repository_path="missing-for-test", slug="trading-test") + dataset = service.register_market_dataset(project, name="BTCUSD 2m", kind="OHLCV", version="v1", reference="fake://btc", content_hash="a" * 64, fields=["timestamp", "open", "high", "low", "close", "volume"], record_count=10, start_at=None, end_at=None, resolution="2m", quality={"duplicates": 0}, temporal_splits=splits()) + cohort = TradingCohort.objects.create(trading_project=project, name="bounded", dataset_version=dataset, policy_snapshot={"minimum_trade_count": 30}) + strategy = service.create_strategy_version(project, name="candidate", genome={"entry_conditions": ["close > prior_close"], "position_sizing": {"kind": "fixed"}}) + experiment = service.propose_experiment(cohort, strategy, contract()) + return service, project, dataset, cohort, strategy, experiment + + +def test_random_time_split_and_overlap_are_rejected(): + service = TradingStudioService() + project = service.import_hyperscalper(repository_path="missing", slug="split-test") + with pytest.raises(ValueError, match="Random"): + service.register_market_dataset(project, name="bad", kind="OHLCV", version="v1", reference="x", content_hash="a", fields=[], record_count=1, start_at=None, end_at=None, resolution="2m", quality={}, temporal_splits={"split_method": "random"}) + bad = splits() + bad["TRAIN"]["start"] = "2026-01-09T00:00:00Z" + with pytest.raises(ValueError, match="non-overlapping"): + service.register_market_dataset(project, name="overlap", kind="OHLCV", version="v1", reference="x", content_hash="b", fields=[], record_count=1, start_at=None, end_at=None, resolution="2m", quality={}, temporal_splits=bad) + + +def test_blocked_features_and_martingale_are_rejected(): + service, project, _, _, _, _ = ready() + feature = FeatureDefinition.objects.create(trading_project=project, name="blocked", implementation_reference="fake://feature", code_hash="f", leakage_status=EvidenceStatus.BLOCKED) + feature_set = service.create_feature_set(project, name="blocked", version="v1", features=[feature]) + with pytest.raises(ValueError, match="BLOCKED"): + service.create_strategy_version(project, name="bad-feature", genome={}, feature_set=feature_set) + with pytest.raises(ValueError, match="Martingale"): + service.create_strategy_version(project, name="bad-sizing", genome={"position_sizing": "martingale"}) + + +def test_same_bar_and_cost_accounting_are_enforced(): + service, _, _, _, _, experiment = ready() + with pytest.raises(ValueError, match="Same-bar"): + service.record_backtest(experiment, split="VALIDATION", execution_model_version="v1", metrics=metrics(), configuration={"same_bar_close_execution": True}) + with pytest.raises(ValueError, match="Net PnL"): + service.record_backtest(experiment, split="VALIDATION", execution_model_version="v1", metrics=metrics(net_pnl=7), configuration={}) + + +def test_negative_after_fees_is_killed_as_economics_failure(): + service, _, _, _, strategy, experiment = ready() + run = service.record_backtest(experiment, split="VALIDATION", execution_model_version="v1", metrics=metrics(gross_pnl=2, fees=3, funding=0, slippage=0, net_pnl=-1), configuration={}) + evaluation = service.judge_backtest(experiment, run, policy={"minimum_trade_count": 30}) + experiment.refresh_from_db() + strategy.refresh_from_db() + assert evaluation.failure_type == FailureType.FEE_DESTROYED + assert experiment.status == ExperimentStatus.REJECTED + assert strategy.stage == StrategyStage.KILLED + + +def test_holdout_is_consumed_and_cannot_drive_adaptive_design(): + service, _, _, _, strategy, experiment = ready() + service.record_backtest(experiment, split="HOLDOUT", execution_model_version="v1", metrics=metrics(), configuration={}) + strategy.refresh_from_db() + assert strategy.holdout_exposure_count == 1 + assert strategy.immutable is True + with pytest.raises(ValueError, match="Consumed holdout"): + service.propose_experiment(experiment.cohort, strategy, contract(controls={"uses_holdout_for_design": True})) + + +def test_micro_live_is_hard_refused_and_no_survivor_report_is_valid(): + service, project, _, _, strategy, _ = ready() + with pytest.raises(ValueError, match="offline-only"): + service.request_micro_live(strategy, Decimal("20")) + report = service.report(project) + assert report.content["counts"]["survivors"] == 0 + assert report.content["offline_only"] is True