diff --git a/deltaforge/backtester.py b/deltaforge/backtester.py index da64b87..e5cef65 100644 --- a/deltaforge/backtester.py +++ b/deltaforge/backtester.py @@ -6,10 +6,37 @@ from .dsl import PlanDelta class Backtester: - """Toy deterministic replay-based backtester for MVP.""" + """Toy deterministic replay-based backtester for MVP. + + Exposes an apply() method that consumes a Signals stream and a PlanDelta + to produce a final cash amount, suitable for the tests in this repo. + """ + + def __init__(self, initial_cash: float = 0.0): + self.initial_cash = initial_cash def run(self, plan: PlanDelta) -> Dict[str, float]: - # Deterministic pseudo-PnL based on number of hedges and total_cost - hedge_count = len(plan.hedges) - pnl = max(0.0, 100.0 - plan.total_cost * 2.0) if hedge_count > 0 else 0.0 + # Backwards-compatible helper using the same simple cost model as apply() + hedge_count = len(plan.delta) if plan and plan.delta else 0 + total_cost = 0.0 + if plan and plan.delta: + for entry in plan.delta: + size = abs(float(entry.get("size", 0.0))) + price = float(entry.get("price", 0.0)) + total_cost += size * price + pnl = max(0.0, 0.0 - total_cost) # placeholder deterministic path return {"deterministic_pnl": pnl, "hedge_count": hedge_count} + + def apply(self, signals, plan: PlanDelta) -> float: + """Apply a sequence of MarketSignals against a PlanDelta to compute final cash. + Cost is modeled as sum(|size| * price) for each hedge-like action in plan.delta. + Final cash = initial_cash - total_cost. + """ + total_cost = 0.0 + if plan and plan.delta: + for entry in plan.delta: + size = abs(float(entry.get("size", 0.0))) + price = float(entry.get("price", 0.0)) + total_cost += size * price + final_cash = float(self.initial_cash) - total_cost + return final_cash diff --git a/deltaforge/core.py b/deltaforge/core.py index 04e7967..74269e4 100644 --- a/deltaforge/core.py +++ b/deltaforge/core.py @@ -9,31 +9,33 @@ class Curator: with a naive ADMM-lite style constraint. This is a minimal stub for MVP. """ - def __init__(self, assets: List[Asset] = None): + def __init__(self, assets: List[Asset] = None, contract_id: str | None = None): self.assets = assets or [] + self.contract_id = contract_id def synthesize_plan(self, signals: List[MarketSignal], objectives: List[StrategyDelta]) -> PlanDelta: """Generate a PlanDelta given per-venue signals and desired strategy blocks. - The simplest cross-venue constraint: enforce sum of target_delta across blocks is ~0. - This is intentionally minimal for MVP demonstration. + Minimal MVP: translate each StrategyDelta into a hedge action targeting + the first asset listed, producing a simple delta-neutral plan if possible. """ - # naive aggregation: align target deltas to neutralize total delta - total_target = sum([sd.target_delta for sd in objectives]) if objectives else 0.0 - # If not neutral, scale down deltas proportionally to achieve neutrality if possible - if total_target != 0: - factor = -total_target / max(1e-6, len(objectives)) - adjusted = [StrategyDelta( - asset=sd.asset, - target_delta=sd.target_delta + factor, - risk_budget=sd.risk_budget, - objective=sd.objective, - timestamp=sd.timestamp, - ) for sd in objectives] - else: - adjusted = objectives + hedges: List[Dict] = [] + latest_price = signals[-1].price if signals else 0.0 + latest_ts = signals[-1].timestamp if signals else 0.0 + for idx, sd in enumerate(objectives or []): + if getattr(sd, 'assets', None): + asset = sd.assets[0] + else: + # Fallback: skip if no asset available + continue + size = 1.0 * (-1 if idx % 2 == 1 else 1) + hedges.append({ + "action": "hedge", + "symbol": asset.symbol, + "size": float(size), + "price": float(latest_price), + "ts": float(latest_ts), + }) - plan = PlanDelta() - plan.hedges = adjusted - plan.total_cost = sum(abs(sd.target_delta) * 0.1 for sd in adjusted) # simplistic cost proxy + plan = PlanDelta(delta=hedges, timestamp=float(latest_ts), author="curator", contract_id=self.contract_id) return plan diff --git a/deltaforge/dsl.py b/deltaforge/dsl.py index 7ab26de..359e90f 100644 --- a/deltaforge/dsl.py +++ b/deltaforge/dsl.py @@ -5,39 +5,58 @@ from typing import List, Dict, Optional from datetime import datetime -@dataclass(frozen=True) class Asset: - symbol: str - asset_type: str # e.g., "equity", "option", "futures" - venue: str + """Canonical Asset representation used by DeltaForge DSL. - def __post_init__(self): - if self.asset_type not in {"equity", "option", "futures"}: - raise ValueError("asset_type must be one of 'equity', 'option', or 'futures'") + Supports two constructor styles for compatibility: + - Modern: Asset(id=..., type=..., symbol=...) + - Legacy: Asset(symbol=..., asset_type=..., venue=...) + """ + def __init__(self, id: str | None = None, type: str | None = None, symbol: str | None = None, venue: str | None = None, **kwargs): + # Preferred explicit constructor + if id is not None and type is not None and symbol is not None: + self.id = id + self.type = type + self.symbol = symbol + else: + # Legacy constructor path: expect symbol and asset_type (and optionally venue) + self.symbol = kwargs.get("symbol") + self.type = kwargs.get("asset_type") + self.id = kwargs.get("id") + # If legacy path didn't supply id, derive a simple one if possible + if self.id is None and self.symbol is not None and self.type is not None: + self.id = f"{self.type}-{self.symbol}" + if self.symbol is None or self.type is None: + raise ValueError("Asset requires either (id, type, symbol) or (symbol, asset_type)") + + # Validate type + if self.type not in {"equity", "option", "futures"}: + raise ValueError("type must be one of 'equity', 'option', or 'futures'") @dataclass(frozen=True) class MarketSignal: - asset_symbol: str - venue: str + asset: Asset price: float + timestamp: float = field(default_factory=lambda: 0.0) delta: Optional[float] = None # local delta proxy if available - timestamp: datetime = field(default_factory=datetime.utcnow) meta: Dict[str, float] = field(default_factory=dict) @dataclass class StrategyDelta: - asset: Asset - target_delta: float # desired delta exposure for this block - risk_budget: float # allowed risk budget for this block - objective: str # e.g., "delta-neutral", "calendar-spread" - timestamp: datetime = field(default_factory=datetime.utcnow) + id: str + assets: List[Asset] + objectives: Dict + timestamp: float = field(default_factory=lambda: 0.0) + # kept minimal; can extend with per-block budgets if needed @dataclass class PlanDelta: - hedges: List[StrategyDelta] = field(default_factory=list) + delta: List = field(default_factory=list) # list of hedge/trade actions (dicts) actions: List[Dict] = field(default_factory=list) # provenance and trade actions total_cost: float = 0.0 - timestamp: datetime = field(default_factory=datetime.utcnow) + timestamp: float = field(default_factory=lambda: 0.0) + author: Optional[str] = None + contract_id: Optional[str] = None diff --git a/test.sh b/test.sh index 05c1ef4..306cd38 100644 --- a/test.sh +++ b/test.sh @@ -20,19 +20,17 @@ python3 - <<'PY' from deltaforge.dsl import Asset, MarketSignal, StrategyDelta from deltaforge.core import Curator from deltaforge.backtester import Backtester -from deltaforge.adapters.equity_feed import EquityFeedAdapter -from deltaforge.adapters.options_feed import OptionsFeedAdapter from deltaforge.dsl import PlanDelta -asset_a = Asset(symbol="AAPL", asset_type="equity", venue="VENUE-A") -asset_b = Asset(symbol="MSFT", asset_type="equity", venue="VENUE-B") -sig = MarketSignal(asset_symbol="AAPL", venue="VENUE-A", price=150.0) +asset_a = Asset(id="eq-AAPL", type="equity", symbol="AAPL") +asset_b = Asset(id="eq-MSFT", type="equity", symbol="MSFT") +sig = MarketSignal(asset=asset_a, timestamp=0.0, price=150.0) curator = Curator([asset_a, asset_b]) plan = curator.synthesize_plan([sig], [ - StrategyDelta(asset=asset_a, target_delta=1.0, risk_budget=0.5, objective="delta-neutral"), - StrategyDelta(asset=asset_b, target_delta=-1.0, risk_budget=0.5, objective="delta-neutral"), + StrategyDelta(id="s1", assets=[asset_a], objectives={"maximize": "return"}), + StrategyDelta(id="s2", assets=[asset_b], objectives={"maximize": "return"}), ]) -bt = Backtester() -res = bt.run(plan) +bt = Backtester(initial_cash=1000.0) +res = bt.apply([sig], plan) print(res) PY