Secrets
One Protocol for credentials. Venues never touch os.environ directly.
horizon.secrets lets venues read broker credentials without hard-coding where credentials live. In dev, environment variables. In production, Vault, AWS Secrets Manager, GCP Secret Manager, or 1Password Connect. Same venue code, different backend.
Protocol
class Secrets(Protocol):
def get(self, key: str, default: str | None = None) -> str | None: ...
def has(self, key: str) -> bool: ...
Two methods is enough for venues. SecretsProvider adds set and rotate for ops tooling. Venue code needs read access only.
Key naming
Keep stable. Do not rename without a migration plan.
<venue>.<field> "alpaca.key", "alpaca.secret"
"ibkr.username", "ibkr.password"
"polygon.api_key"
"polymarket.private_key"
<service>.<field> "slack.webhook", "pagerduty.routing_key"
Keys are case-insensitive (providers normalize via .lower()).
Backends
EnvSecrets (default)
Reads process environment variables. Maps alpaca.key to ALPACA_KEY by default (uppercase, dots to underscores).
from horizon.secrets import EnvSecrets
secrets = EnvSecrets()
secrets.get("alpaca.key") # ALPACA_KEY
secrets.get("alpaca.secret") # ALPACA_SECRET
secrets.get("polygon.api_key") # POLYGON_API_KEY
Prefix
secrets = EnvSecrets(prefix="HORIZON_")
secrets.get("alpaca.key") # HORIZON_ALPACA_KEY
Useful when multiple agents share a host and need per-agent env namespaces.
Explicit mapping
secrets = EnvSecrets(mapping={
"alpaca.key": "MY_LEGACY_ALPACA_KEY_NAME",
})
require()
Returns the value or raises SecretsError on missing or empty. Use for required credentials so startup fails fast instead of failing on the first order.
from horizon.secrets import EnvSecrets, SecretsError
try:
api_key = secrets.require("alpaca.key")
except SecretsError as e:
print(f"Missing credential: {e}")
exit(1)
DictSecrets (tests)
from horizon.secrets import DictSecrets
fake = DictSecrets({
"alpaca.key": "test_key",
"alpaca.secret": "test_secret",
})
venue = Alpaca(secrets=fake, paper=True)
Not production. Isolates tests from environment state.
L1 and L2
VaultSecrets. HashiCorp Vault.AwsSecretsManagerSecrets. AWS SM.GcpSecretManagerSecrets. GCP SM.OnePasswordConnectSecrets. Smaller shops.
Each implements the Secrets / SecretsProvider Protocol. Venues do not change.
Venue integration
Shipped: Alpaca reads via Secrets:
def __init__(self, *, secrets: Secrets | None = None, paper: bool = True, ...):
self._secrets = secrets
# ...
# Resolution: explicit api_key/api_secret -> Secrets -> EnvSecrets fallback
Pass secrets= at construction to plug Vault, AWS SM, or DictSecrets in without changing venue logic.
Auditing secret access
L1 will optionally emit AuditCategory.SecretAccess on every secrets.get(...). The event records the key name; the value is never logged. See Audit trail.
# L1
from horizon.audit import AuditLog
from horizon.secrets import EnvSecrets
audit_log = AuditLog(sink=SQLiteSink("audit.db"))
raw_secrets = EnvSecrets()
secrets = AuditingSecrets(raw_secrets, audit_log)
secrets.get("alpaca.key")
# AuditEvent: category=SecretAccess, actor="system", payload={"key": "alpaca.key"}
Rotation
SecretsProvider.rotate(key) targets backends that support it (Vault, AWS SM). Typical pattern: a scheduled job calls rotate and triggers a venue reconnect.
# Ops-side
new_value = secrets.rotate("alpaca.key")
venue.reconnect()
Today
- Use
EnvSecrets()in dev. Route reads through the Protocol. - Plan a production backend before staging. Switch is a one-line change in the venue construction site.
- Do not hard-code credentials. Do not log them. Do not include them in exception messages.