The signal path
TradingView webhook → API Gateway (secret path token, since TradingView cannot set headers) → ingest Lambda persists the signal to DynamoDB and fans out only to accounts flagged provisioned and trading → SQS FIFO with per-account message groups, so each account's entries and exits stay strictly ordered → an in-VPC forward Lambda resolves which ECS task hosts this account and POSTs the signal to it → the executor qualifies the contract, applies latency and risk gates, and places bracket orders — fills, P&L and per-hop latency land back in DynamoDB.
Problem solved
Running automated strategies across many IBKR accounts normally means hand-managed gateways, ports and credentials, no ordering guarantees when signals arrive close together, and no way to prove that what the strategy printed is what the broker actually filled.
- Route one TradingView alert to many accounts safely, in order, per account.
- Provision and retire whole accounts without touching a server.
- Keep credentials out of code, disks and shell history entirely.
- Answer "did every alert become the right fill?" with data, daily.
Account = one ECS service
- Each account is an ECS task: an IB Gateway container plus an executor container, credentials injected at start from Secrets Manager — never on disk.
- A reconciler Lambda is the control plane: it polls Secrets Manager, maintains the accounts table, and creates/updates/deletes one ECS service per account. Onboarding an account = creating a secret.
- ECS-on-EC2 with an auto-scaling group, capacity provider and memory binpacking; operator access via SSM Session Manager — no SSH, no open ports.
- Failure domains stay small: a broken gateway affects exactly one account.
Service discovery over DNS — a latency and cost win
def _resolve_via_dns(account):
fqdn = f"acc-{account}.{NAMESPACE_NAME}"
srv = _dns.resolve(fqdn, "SRV")
# MULTIVALUE routing: lowest priority wins, then highest weight.
rec = min(srv, key=lambda r: (r.priority, -r.weight))
port, target = str(rec.port), str(rec.target).rstrip(".")
if not port or not target:
raise RuntimeError(f"{fqdn} SRV missing port/target")
return _dns.resolve(target, "A")[0].address, port
Cloud Map's control-plane API calls (list_services / list_instances) needed a paid VPC
interface endpoint and two TLS handshakes per signal. Replacing them with plain DNS SRV lookups through the
VPC resolver — same source of truth, one UDP round trip — removed the endpoint and ~150–190 ms of
per-signal latency. Every signal also carries per-hop timestamp breadcrumbs (API Gateway request
time, SQS sent/received attributes, forward, executor receive, fill) with zero added network calls on the
trading path — aggregatable in CloudWatch Logs Insights.
End-to-end trade reconciliation
The platform doesn't just execute — it proves it executed correctly.
The 3-way join
- A Chrome extension (Manifest V3) captures TradingView's List-of-Trades into S3; a reconciliation Lambda joins TradingView trade ↔ ingested signal ↔ broker fill.
- Matching uses normalized symbols — a futures continuous-contract normalizer maps front-month aliases to real contract codes; without it the join silently fails.
- An asymmetric time window: a fill can only land at-or-after its signal — a symmetric window was false-flagging real trades.
- Every row classified along a ladder:
signal_missing → fill_missing → price_mismatch → pnl_gap → high_latency → matched.
Execution gates
- The executor rejects stale signals (maximum allowed latency) and refuses entries without explicit take-profit and stop-loss.
- Bracket orders (entry + TP + SL) placed only after unambiguous contract qualification.
- Pre-close snapshotting and post-open restoration handle overnight risk windows per timezone.
- Structured
ALARMlog markers become CloudWatch metrics and alerts automatically — a new alarm condition is a log line, not an infrastructure change.
Bracket execution with latency and risk checks (executor)
def execute_signal_for_account(IB_INSTANCE, sig: Signal, settings: Settings) -> dict:
contract = build_contract(sig)
qualified = qualify_contract(contract)
if not qualified or len(qualified) != 1:
log_step("[ALARM] Ambiguous contract; skipping")
return {"ok": True, "action": "skipped_ambiguous_contract"}
qty = current_position_qty(IB_INSTANCE, qualified[0])
sig_age = time.time() - sig.signal_timestamp if sig.signal_timestamp else None
allow_entry = sig_age is None or sig_age <= int(settings.execution_delay)
if qty == 0 and sig.desired_direction != 0:
if not sig.risk_valid:
log_step("[ALARM][EXEC] TP or SL missing, skipping execution")
return {"ok": True, "action": "entry_ignored_no_risk_params"}
if not allow_entry:
return {"ok": True, "action": "entry_skipped_due_to_latency"}
open_position_with_brackets(IB_INSTANCE, qualified[0],
sig.desired_direction, sig.desired_qty,
sig.take_profit, sig.stop_loss, sig.target_percentage)
return {"ok": True, "action": "opened"}
Security & operations
Security posture
- Everything in-VPC with security-group-to-security-group rules only; the sole public surface is the tokenized webhook.
- Zero long-lived IAM keys: SSO locally, IAM roles everywhere else — no key material exists to leak.
- The ops dashboard is a CloudFront + S3 static app behind an auth function, backed by a small HTTP API Lambda with per-column filtering.
Cost & ops discipline
- Per-task smoke tests and written review checklists gate every milestone.
- A real monthly bill variance was traced to a specific oversized status object and a full-table scan — then fixed at the source.
standby/wakescripts park the entire platform without deleting infrastructure.- DynamoDB on-demand with TTLs; the cluster binpacks accounts onto the fewest instances.
Deployment — 7 ordered stacks
- Network — VPC, subnets, security groups, Cloud Map namespace.
- Data — DynamoDB tables (signals, accounts, fills), on-demand, with TTL.
- Cluster — ECS on EC2: auto-scaling group, capacity provider, SSM access.
- Signals — API Gateway, ingest Lambda, SQS FIFO + DLQ, in-VPC forward Lambda.
- Ops — metric filters on structured log markers, alarms, alarm-formatter Lambda.
- Dashboard — CloudFront + S3 static app, auth function, HTTP API.
- Reconciliation — trade-capture ingestion and the 3-way recon Lambda.
All deployed in order by one PowerShell script; onboarding a new account afterwards is a single secret in Secrets Manager — the reconciler does the rest.