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:
env,config,llm— environment bindings, merged configuration, and the LLM provider.broker— aPolicyEngine-wrapped broker. Callctx.broker.buy()andctx.broker.sell()to place trades; the policy engine validates risk limits before execution.state— per-strategy key/value state. Usectx.state.get(key)andctx.state.set(key, value)to persist data across alarm cycles.signals— the current signal cache (all gathered signals from the current cycle).log,trackLLMCost,sleep— logging, LLM cost tracking, and async sleep utilities.
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 |
wrangler.jsonc.
For all configuration options, API endpoints, and app operations, see Configuration and Sentinel.