494 lines
14 KiB
Python
494 lines
14 KiB
Python
"""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
|
|
]
|