Custom Strategies

Build your own trading strategy without touching core files.

Overview

The core harness (src/durable-objects/mahoraga-harness.ts) is a thin orchestrator (~1,400 lines). It handles scheduling, state persistence, and the alarm loop — but all customizable trading logic lives in strategy modules under src/strategy/.

To build your own strategy, you create a Strategy object that implements the Strategy interface defined in src/strategy/types.ts. Then you change one import line in src/strategy/index.ts to activate it. No need to touch the harness or any core files.

The Strategy Interface

Your strategy must satisfy this interface (simplified from src/strategy/types.ts):

export interface Strategy {
  name: string;
  defaultConfig: Record<string, unknown>;
  gatherers: Gatherer[];
  prompts: { ... };
  selectEntries: (ctx, research, positions, account) => BuyCandidate[];
  selectExits: (ctx, positions, account) => SellCandidate[];
}

Each field gives the harness what it needs: gatherers to collect signals, prompts to drive LLM research, and selectEntries/selectExits to make trading decisions.

Strategy Context

Every strategy method receives a StrategyContext object. This is your interface to the outside world:

Adding a Gatherer

Gatherers collect signals from external sources. Each gatherer returns an array of Signal objects. Create a new file in your strategy folder:

// src/strategy/my-strategy/gatherers/my-source.ts
import type { Gatherer, StrategyContext } from "../../types";

export const myGatherer: Gatherer = {
  name: "my-source",
  gather: async (ctx: StrategyContext) => {
    const res = await fetch("https://api.example.com/data");
    const data = await res.json();
    return data.items.map(item => ({
      symbol: item.ticker,
      source: "my_source",
      source_detail: "my_source_v1",
      sentiment: item.score,
      raw_sentiment: item.score,
      volume: 1,
      freshness: 1.0,
      source_weight: 0.9,
      reason: `MySource: ${item.summary}`,
      timestamp: Date.now(),
    }));
  },
};

Custom Entry/Exit Rules

selectEntries receives research results and returns buy candidates. selectExits receives open positions and returns sell candidates.

Entry example

selectEntries: (ctx, research, positions, account) => {
  return research
    .filter(r => r.verdict === "BUY" && r.confidence >= 0.7)
    .filter(r => !positions.find(p => p.symbol === r.symbol))
    .map(r => ({
      symbol: r.symbol,
      reason: r.reasoning,
      confidence: r.confidence,
      maxAllocation: 0.05, // 5% of portfolio per position
    }));
}

Exit example

selectExits: (ctx, positions, account) => {
  return positions
    .filter(p => p.unrealizedPnlPct >= 0.15 || p.unrealizedPnlPct <= -0.08)
    .map(p => ({
      symbol: p.symbol,
      reason: p.unrealizedPnlPct >= 0.15 ? "take-profit" : "stop-loss",
      qty: p.qty,
    }));
}

Custom Prompts

Prompt templates control what the LLM sees and how it responds. Each prompt builder is a function that returns { system, user, maxTokens }:

// src/strategy/my-strategy/prompts/research.ts
import type { ResearchSignalPromptBuilder } from "../../types";

export const myResearchPrompt: ResearchSignalPromptBuilder = (
  symbol, sentiment, sources, price
) => ({
  system: "You are a contrarian analyst. Only recommend BUY for oversold bounces.",
  user: `Analyze ${symbol} at $${price}. Sentiment: ${sentiment}. Sources: ${sources.join(", ")}.
  JSON: { "verdict": "BUY|SKIP", "confidence": 0-1, "reasoning": "..." }`,
  maxTokens: 200,
});

Assembling Your Strategy

Bring your gatherers, prompts, and rules together into a single Strategy object. You can mix default modules with your own:

// src/strategy/my-strategy/index.ts
import { stocktwitsGatherer } from "../default/gatherers/stocktwits";
import { myGatherer } from "./gatherers/my-source";
import { myResearchPrompt } from "./prompts/research";

export const myStrategy: Strategy = {
  name: "my-strategy",
  defaultConfig: { min_sentiment_score: 0.2 },
  gatherers: [stocktwitsGatherer, myGatherer],
  prompts: {
    researchSignal: myResearchPrompt,
    researchPosition: null,
    analyzeSignals: null,
    premarketAnalysis: null,
  },
  selectEntries: (ctx, research, positions, account) => { ... },
  selectExits: (ctx, positions, account) => { ... },
};

Activating Your Strategy

Once assembled, activate your strategy by changing one import in the strategy index file:

// src/strategy/index.ts — change this one line
import { myStrategy } from "./my-strategy";
export const activeStrategy = myStrategy;

That is the only change needed. The harness reads activeStrategy and uses it for every alarm cycle.

Default Strategy Reference

The built-in default strategy lives in src/strategy/default/. You can use any of its modules in your own strategy, or use it as a reference implementation.

File Purpose
index.ts Strategy assembly
config.ts DEFAULT_CONFIG, SOURCE_CONFIG
gatherers/stocktwits.ts StockTwits sentiment
gatherers/reddit.ts Reddit (4 subreddits)
gatherers/sec.ts SEC EDGAR filings
gatherers/crypto.ts Crypto momentum signals
gatherers/twitter.ts Twitter confirmation
prompts/research.ts Signal & position research prompts
prompts/analyst.ts Analyst LLM prompt
prompts/premarket.ts Pre-market analysis prompt
rules/entries.ts Buy candidate selection
rules/exits.ts Sell candidate selection (TP/SL/staleness)
rules/staleness.ts Staleness scoring
rules/options.ts Options contract selection
rules/crypto-trading.ts Crypto entry/exit rules
Tip: The harness stores state in Durable Object storage. To reset state, redeploy with a new migration tag in wrangler.jsonc.

For all configuration options, API endpoints, and app operations, see Configuration and Sentinel.