phase 14-15: docker build validation and helm deployment
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
"""Execution audit trail - records every step from recommendation to market outcome.
|
||||
|
||||
Writes structured audit events to the audit_events table so the full
|
||||
decision chain is traceable: recommendation → risk evaluation → order
|
||||
submission → broker response → fill/rejection/cancellation.
|
||||
|
||||
Each event captures the entity type, entity ID, event type, actor,
|
||||
and a JSONB data payload with stage-specific details.
|
||||
|
||||
Requirements: 8.3, 11.3
|
||||
Design: Section 4.9 (Broker Adapter), Section 6.1 (PostgreSQL audit_events)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger("audit")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event type constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Recommendation stage
|
||||
AUDIT_RECOMMENDATION_GENERATED = "recommendation.generated"
|
||||
AUDIT_RECOMMENDATION_SUPPRESSED = "recommendation.suppressed"
|
||||
|
||||
# Risk evaluation stage
|
||||
AUDIT_RISK_EVALUATED = "risk.evaluated"
|
||||
AUDIT_RISK_REJECTED = "risk.rejected"
|
||||
|
||||
# Order lifecycle
|
||||
AUDIT_ORDER_SUBMITTED = "order.submitted"
|
||||
AUDIT_ORDER_ACCEPTED = "order.accepted"
|
||||
AUDIT_ORDER_FILLED = "order.filled"
|
||||
AUDIT_ORDER_REJECTED = "order.rejected"
|
||||
AUDIT_ORDER_CANCELLED = "order.cancelled"
|
||||
AUDIT_ORDER_DUPLICATE = "order.duplicate_prevented"
|
||||
|
||||
# Position changes
|
||||
AUDIT_POSITION_OPENED = "position.opened"
|
||||
AUDIT_POSITION_CLOSED = "position.closed"
|
||||
AUDIT_POSITION_UPDATED = "position.updated"
|
||||
|
||||
# Trading mode changes
|
||||
AUDIT_TRADING_MODE_CHANGED = "trading.mode_changed"
|
||||
|
||||
# Operator approval workflow
|
||||
AUDIT_APPROVAL_REQUESTED = "approval.requested"
|
||||
AUDIT_APPROVAL_APPROVED = "approval.approved"
|
||||
AUDIT_APPROVAL_REJECTED = "approval.rejected"
|
||||
AUDIT_APPROVAL_EXPIRED = "approval.expired"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core audit writer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INSERT_AUDIT_EVENT = """
|
||||
INSERT INTO audit_events (id, event_type, entity_type, entity_id, actor, data, created_at)
|
||||
VALUES ($1::uuid, $2, $3, $4::uuid, $5, $6::jsonb, $7)
|
||||
"""
|
||||
|
||||
|
||||
async def record_audit_event(
|
||||
pool: asyncpg.Pool,
|
||||
event_type: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
data: dict[str, Any],
|
||||
actor: str = "system",
|
||||
timestamp: datetime | None = None,
|
||||
) -> str:
|
||||
"""Write a single audit event to PostgreSQL.
|
||||
|
||||
Returns the audit event UUID.
|
||||
"""
|
||||
event_id = str(uuid.uuid4())
|
||||
ts = timestamp or datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
await pool.execute(
|
||||
_INSERT_AUDIT_EVENT,
|
||||
event_id,
|
||||
event_type,
|
||||
entity_type,
|
||||
entity_id,
|
||||
actor,
|
||||
json.dumps(data, default=str),
|
||||
ts,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to write audit event %s for %s/%s",
|
||||
event_type, entity_type, entity_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return ""
|
||||
|
||||
return event_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience helpers for each execution stage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def audit_recommendation_generated(
|
||||
pool: asyncpg.Pool,
|
||||
recommendation_id: str,
|
||||
ticker: str,
|
||||
action: str,
|
||||
mode: str,
|
||||
confidence: float,
|
||||
evidence_count: int,
|
||||
suppressed: bool = False,
|
||||
) -> str:
|
||||
"""Record that a recommendation was generated."""
|
||||
event_type = AUDIT_RECOMMENDATION_SUPPRESSED if suppressed else AUDIT_RECOMMENDATION_GENERATED
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=event_type,
|
||||
entity_type="recommendation",
|
||||
entity_id=recommendation_id,
|
||||
data={
|
||||
"ticker": ticker,
|
||||
"action": action,
|
||||
"mode": mode,
|
||||
"confidence": confidence,
|
||||
"evidence_count": evidence_count,
|
||||
"suppressed": suppressed,
|
||||
},
|
||||
actor="recommendation_worker",
|
||||
)
|
||||
|
||||
|
||||
async def audit_risk_evaluated(
|
||||
pool: asyncpg.Pool,
|
||||
evaluation_id: str,
|
||||
recommendation_id: str | None,
|
||||
ticker: str,
|
||||
eligible: bool,
|
||||
allowed_mode: str,
|
||||
rejection_reasons: list[str],
|
||||
check_count: int,
|
||||
) -> str:
|
||||
"""Record a risk evaluation result."""
|
||||
event_type = AUDIT_RISK_REJECTED if not eligible else AUDIT_RISK_EVALUATED
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=event_type,
|
||||
entity_type="risk_evaluation",
|
||||
entity_id=evaluation_id,
|
||||
data={
|
||||
"recommendation_id": recommendation_id,
|
||||
"ticker": ticker,
|
||||
"eligible": eligible,
|
||||
"allowed_mode": allowed_mode,
|
||||
"rejection_reasons": rejection_reasons,
|
||||
"check_count": check_count,
|
||||
},
|
||||
actor="risk_engine",
|
||||
)
|
||||
|
||||
|
||||
async def audit_order_submitted(
|
||||
pool: asyncpg.Pool,
|
||||
order_id: str,
|
||||
ticker: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
order_type: str,
|
||||
idempotency_key: str,
|
||||
recommendation_id: str | None = None,
|
||||
evaluation_id: str | None = None,
|
||||
) -> str:
|
||||
"""Record that an order was submitted to the broker."""
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=AUDIT_ORDER_SUBMITTED,
|
||||
entity_type="order",
|
||||
entity_id=order_id,
|
||||
data={
|
||||
"ticker": ticker,
|
||||
"side": side,
|
||||
"quantity": quantity,
|
||||
"order_type": order_type,
|
||||
"idempotency_key": idempotency_key,
|
||||
"recommendation_id": recommendation_id,
|
||||
"evaluation_id": evaluation_id,
|
||||
},
|
||||
actor="broker_service",
|
||||
)
|
||||
|
||||
|
||||
async def audit_order_filled(
|
||||
pool: asyncpg.Pool,
|
||||
order_id: str,
|
||||
ticker: str,
|
||||
side: str,
|
||||
fill_quantity: float,
|
||||
fill_price: float | None,
|
||||
broker_order_id: str,
|
||||
) -> str:
|
||||
"""Record that an order was filled by the broker."""
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=AUDIT_ORDER_FILLED,
|
||||
entity_type="order",
|
||||
entity_id=order_id,
|
||||
data={
|
||||
"ticker": ticker,
|
||||
"side": side,
|
||||
"fill_quantity": fill_quantity,
|
||||
"fill_price": fill_price,
|
||||
"broker_order_id": broker_order_id,
|
||||
},
|
||||
actor="broker_service",
|
||||
)
|
||||
|
||||
|
||||
async def audit_order_rejected(
|
||||
pool: asyncpg.Pool,
|
||||
order_id: str,
|
||||
ticker: str,
|
||||
reason: str,
|
||||
source: str = "broker",
|
||||
) -> str:
|
||||
"""Record that an order was rejected (by risk engine or broker)."""
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=AUDIT_ORDER_REJECTED,
|
||||
entity_type="order",
|
||||
entity_id=order_id,
|
||||
data={
|
||||
"ticker": ticker,
|
||||
"reason": reason,
|
||||
"rejection_source": source,
|
||||
},
|
||||
actor="broker_service",
|
||||
)
|
||||
|
||||
|
||||
async def audit_order_cancelled(
|
||||
pool: asyncpg.Pool,
|
||||
order_id: str,
|
||||
ticker: str,
|
||||
broker_order_id: str,
|
||||
) -> str:
|
||||
"""Record that an order was cancelled."""
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=AUDIT_ORDER_CANCELLED,
|
||||
entity_type="order",
|
||||
entity_id=order_id,
|
||||
data={
|
||||
"ticker": ticker,
|
||||
"broker_order_id": broker_order_id,
|
||||
},
|
||||
actor="broker_service",
|
||||
)
|
||||
|
||||
|
||||
async def audit_duplicate_prevented(
|
||||
pool: asyncpg.Pool,
|
||||
order_id: str,
|
||||
ticker: str,
|
||||
idempotency_key: str,
|
||||
detected_via: str,
|
||||
) -> str:
|
||||
"""Record that a duplicate order was prevented."""
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=AUDIT_ORDER_DUPLICATE,
|
||||
entity_type="order",
|
||||
entity_id=order_id,
|
||||
data={
|
||||
"ticker": ticker,
|
||||
"idempotency_key": idempotency_key,
|
||||
"detected_via": detected_via,
|
||||
},
|
||||
actor="broker_service",
|
||||
)
|
||||
|
||||
|
||||
async def audit_position_change(
|
||||
pool: asyncpg.Pool,
|
||||
order_id: str,
|
||||
ticker: str,
|
||||
side: str,
|
||||
quantity_before: float,
|
||||
quantity_after: float,
|
||||
avg_entry_before: float,
|
||||
avg_entry_after: float,
|
||||
) -> str:
|
||||
"""Record a position change resulting from a fill."""
|
||||
if quantity_before == 0 and quantity_after > 0:
|
||||
event_type = AUDIT_POSITION_OPENED
|
||||
elif quantity_after == 0:
|
||||
event_type = AUDIT_POSITION_CLOSED
|
||||
else:
|
||||
event_type = AUDIT_POSITION_UPDATED
|
||||
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=event_type,
|
||||
entity_type="position",
|
||||
entity_id=order_id,
|
||||
data={
|
||||
"ticker": ticker,
|
||||
"side": side,
|
||||
"quantity_before": quantity_before,
|
||||
"quantity_after": quantity_after,
|
||||
"avg_entry_before": avg_entry_before,
|
||||
"avg_entry_after": avg_entry_after,
|
||||
},
|
||||
actor="broker_service",
|
||||
)
|
||||
|
||||
|
||||
async def audit_approval_requested(
|
||||
pool: asyncpg.Pool,
|
||||
approval_id: str,
|
||||
ticker: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
estimated_value: float,
|
||||
recommendation_id: str | None = None,
|
||||
expires_at: str | None = None,
|
||||
) -> str:
|
||||
"""Record that an operator approval was requested for a live order."""
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=AUDIT_APPROVAL_REQUESTED,
|
||||
entity_type="approval",
|
||||
entity_id=approval_id,
|
||||
data={
|
||||
"ticker": ticker,
|
||||
"side": side,
|
||||
"quantity": quantity,
|
||||
"estimated_value": estimated_value,
|
||||
"recommendation_id": recommendation_id,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
actor="broker_service",
|
||||
)
|
||||
|
||||
|
||||
async def audit_approval_reviewed(
|
||||
pool: asyncpg.Pool,
|
||||
approval_id: str,
|
||||
ticker: str,
|
||||
approved: bool,
|
||||
reviewed_by: str = "operator",
|
||||
review_note: str = "",
|
||||
) -> str:
|
||||
"""Record that an operator reviewed an approval request."""
|
||||
event_type = AUDIT_APPROVAL_APPROVED if approved else AUDIT_APPROVAL_REJECTED
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=event_type,
|
||||
entity_type="approval",
|
||||
entity_id=approval_id,
|
||||
data={
|
||||
"ticker": ticker,
|
||||
"approved": approved,
|
||||
"reviewed_by": reviewed_by,
|
||||
"review_note": review_note,
|
||||
},
|
||||
actor=reviewed_by,
|
||||
)
|
||||
|
||||
|
||||
async def audit_approval_expired(
|
||||
pool: asyncpg.Pool,
|
||||
approval_id: str,
|
||||
ticker: str,
|
||||
) -> str:
|
||||
"""Record that an approval request expired without review."""
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=AUDIT_APPROVAL_EXPIRED,
|
||||
entity_type="approval",
|
||||
entity_id=approval_id,
|
||||
data={"ticker": ticker},
|
||||
actor="system",
|
||||
)
|
||||
|
||||
|
||||
async def audit_trading_mode_changed(
|
||||
pool: asyncpg.Pool,
|
||||
config_id: str,
|
||||
old_mode: str,
|
||||
new_mode: str,
|
||||
actor: str = "operator",
|
||||
) -> str:
|
||||
"""Record a trading mode change."""
|
||||
return await record_audit_event(
|
||||
pool,
|
||||
event_type=AUDIT_TRADING_MODE_CHANGED,
|
||||
entity_type="risk_config",
|
||||
entity_id=config_id,
|
||||
data={
|
||||
"old_mode": old_mode,
|
||||
"new_mode": new_mode,
|
||||
},
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query helpers for audit trail retrieval (Requirement 11.3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FETCH_AUDIT_TRAIL_FOR_ORDER = """
|
||||
SELECT id, event_type, entity_type, entity_id, actor, data, created_at
|
||||
FROM audit_events
|
||||
WHERE entity_id = $1::uuid
|
||||
OR data->>'recommendation_id' = $2
|
||||
OR data->>'order_id' = $2
|
||||
ORDER BY created_at ASC
|
||||
"""
|
||||
|
||||
_FETCH_AUDIT_TRAIL_BY_ENTITY = """
|
||||
SELECT id, event_type, entity_type, entity_id, actor, data, created_at
|
||||
FROM audit_events
|
||||
WHERE entity_type = $1 AND entity_id = $2::uuid
|
||||
ORDER BY created_at ASC
|
||||
"""
|
||||
|
||||
_FETCH_FULL_EXECUTION_TRAIL = """
|
||||
SELECT id, event_type, entity_type, entity_id, actor, data, created_at
|
||||
FROM audit_events
|
||||
WHERE entity_id = $1::uuid
|
||||
OR entity_id IN (
|
||||
SELECT entity_id FROM audit_events
|
||||
WHERE data->>'recommendation_id' = $2
|
||||
)
|
||||
ORDER BY created_at ASC
|
||||
"""
|
||||
|
||||
|
||||
async def get_order_audit_trail(
|
||||
pool: asyncpg.Pool,
|
||||
order_id: str,
|
||||
recommendation_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch the full audit trail for an order, including related recommendation and risk events.
|
||||
|
||||
Returns events ordered chronologically so the full decision chain
|
||||
is visible: recommendation → risk → order → fill/reject.
|
||||
"""
|
||||
ref_id = recommendation_id or order_id
|
||||
rows = await pool.fetch(_FETCH_AUDIT_TRAIL_FOR_ORDER, order_id, ref_id)
|
||||
return [
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"event_type": row["event_type"],
|
||||
"entity_type": row["entity_type"],
|
||||
"entity_id": str(row["entity_id"]),
|
||||
"actor": row["actor"],
|
||||
"data": row["data"] if isinstance(row["data"], dict) else json.loads(row["data"]),
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_entity_audit_trail(
|
||||
pool: asyncpg.Pool,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch all audit events for a specific entity."""
|
||||
rows = await pool.fetch(_FETCH_AUDIT_TRAIL_BY_ENTITY, entity_type, entity_id)
|
||||
return [
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"event_type": row["event_type"],
|
||||
"entity_type": row["entity_type"],
|
||||
"entity_id": str(row["entity_id"]),
|
||||
"actor": row["actor"],
|
||||
"data": row["data"] if isinstance(row["data"], dict) else json.loads(row["data"]),
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
Reference in New Issue
Block a user