DhanHQ 2.8.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +19 -0
- data/README.md +16 -5
- data/docs/RAILS_WEBSOCKET_INTEGRATION.md +1 -1
- data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +1 -1
- data/docs/TECHNICAL_ANALYSIS.md +1 -1
- data/exe/DhanHQ +1 -1
- data/lib/DhanHQ/agent/order_preview.rb +50 -0
- data/lib/DhanHQ/agent/policy.rb +51 -0
- data/lib/DhanHQ/agent/tool_registry.rb +250 -0
- data/lib/DhanHQ/agent.rb +12 -0
- data/lib/DhanHQ/ai/context_builder.rb +145 -0
- data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
- data/lib/DhanHQ/ai.rb +27 -0
- data/lib/DhanHQ/auth.rb +0 -1
- data/lib/DhanHQ/client.rb +1 -3
- data/lib/DhanHQ/constants.rb +2 -0
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
- data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
- data/lib/DhanHQ/core/auth_api.rb +0 -1
- data/lib/DhanHQ/errors.rb +4 -0
- data/lib/DhanHQ/events/base.rb +203 -0
- data/lib/DhanHQ/events/bus.rb +158 -0
- data/lib/DhanHQ/events.rb +40 -0
- data/lib/DhanHQ/indicators.rb +283 -0
- data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
- data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
- data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
- data/lib/DhanHQ/market_data.rb +25 -0
- data/lib/DhanHQ/mcp/server.rb +72 -0
- data/lib/DhanHQ/mcp.rb +10 -0
- data/lib/DhanHQ/models/funds.rb +12 -0
- data/lib/DhanHQ/models/holding.rb +42 -0
- data/lib/DhanHQ/models/iceberg_order.rb +139 -0
- data/lib/DhanHQ/models/instrument.rb +36 -0
- data/lib/DhanHQ/models/order.rb +95 -0
- data/lib/DhanHQ/models/position.rb +66 -0
- data/lib/DhanHQ/models/search_result.rb +12 -0
- data/lib/DhanHQ/models/trade.rb +13 -0
- data/lib/DhanHQ/models/twap_order.rb +136 -0
- data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
- data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
- data/lib/DhanHQ/option_analytics.rb +36 -0
- data/lib/DhanHQ/resources/iceberg_orders.rb +61 -0
- data/lib/DhanHQ/resources/twap_orders.rb +61 -0
- data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
- data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
- data/lib/DhanHQ/risk/checks/options.rb +46 -0
- data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
- data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
- data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
- data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
- data/lib/DhanHQ/risk/pipeline.rb +65 -0
- data/lib/DhanHQ/risk.rb +250 -0
- data/lib/DhanHQ/skills/base.rb +132 -0
- data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +87 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +93 -0
- data/lib/DhanHQ/skills/builtin/square_off_all.rb +45 -0
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +48 -0
- data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
- data/lib/DhanHQ/skills/registry.rb +101 -0
- data/lib/DhanHQ/skills/workflow.rb +66 -0
- data/lib/DhanHQ/skills.rb +29 -0
- data/lib/DhanHQ/strategy/base.rb +189 -0
- data/lib/DhanHQ/strategy.rb +40 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/decoder.rb +57 -19
- data/lib/DhanHQ.rb +3 -0
- data/lib/dhan_hq/agent.rb +3 -0
- data/lib/dhan_hq/analysis.rb +9 -0
- data/lib/dhan_hq/mcp.rb +3 -0
- data/lib/dhan_hq/ta.rb +5 -0
- data/lib/dhan_hq.rb +27 -4
- data/lib/ta/technical_analysis.rb +3 -1
- data/skills/dhanhq-ruby/SKILL.md +74 -0
- data/skills/dhanhq-ruby/references/market_data.md +3 -0
- data/skills/dhanhq-ruby/references/orders.md +7 -0
- metadata +61 -20
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
module Builtin
|
|
6
|
+
# Skill to exit a specific position by symbol.
|
|
7
|
+
#
|
|
8
|
+
# Steps: find position → exit position → return result.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# result = DhanHQ::Skills::Registry.call("square_off_position",
|
|
12
|
+
# symbol: "NIFTY",
|
|
13
|
+
# exchange_segment: "IDX_I"
|
|
14
|
+
# )
|
|
15
|
+
#
|
|
16
|
+
class SquareOffPosition < Base
|
|
17
|
+
param :symbol, type: :string, required: true
|
|
18
|
+
param :exchange_segment, type: :string, required: true
|
|
19
|
+
|
|
20
|
+
step :find_position, priority: 1
|
|
21
|
+
step :exit_position, priority: 2
|
|
22
|
+
|
|
23
|
+
def find_position(ctx)
|
|
24
|
+
positions = DhanHQ::Models::Position.all
|
|
25
|
+
target = positions.find do |p|
|
|
26
|
+
seg = p[:exchange_segment] || p["exchange_segment"]
|
|
27
|
+
sym = p[:trading_symbol] || p["tradingSymbol"] || p[:symbol] || p["symbol"]
|
|
28
|
+
seg.to_s == ctx[:exchange_segment].to_s && sym.to_s.upcase == ctx[:symbol].to_s.upcase
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
raise ArgumentError, "No open position found for #{ctx[:symbol]} on #{ctx[:exchange_segment]}" unless target
|
|
32
|
+
|
|
33
|
+
ctx[:position] = target
|
|
34
|
+
ctx[:security_id] = target[:security_id] || target["securityId"]
|
|
35
|
+
ctx[:trading_symbol] = target[:trading_symbol] || target["tradingSymbol"]
|
|
36
|
+
ctx[:net_quantity] = (target[:net_quantity] || target["netQuantity"] || target.net_quantity).to_i
|
|
37
|
+
ctx
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def exit_position(ctx)
|
|
41
|
+
ctx[:exit_result] = DhanHQ::Models::Position.exit_all!
|
|
42
|
+
ctx[:exited] = true
|
|
43
|
+
ctx
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
module Builtin
|
|
6
|
+
# Skill to build a long strangle (buy OTM CE + buy OTM PE).
|
|
7
|
+
#
|
|
8
|
+
# Steps: find instrument → spot price → option chain → select strikes → build intent.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# result = DhanHQ::Skills::Registry.call("strangle",
|
|
12
|
+
# symbol: "NIFTY",
|
|
13
|
+
# expiry: "2026-01-30",
|
|
14
|
+
# quantity: 50,
|
|
15
|
+
# offset_pct: 1.0
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
class Strangle < Base
|
|
19
|
+
param :symbol, type: :string, required: true
|
|
20
|
+
param :expiry, type: :string, required: true
|
|
21
|
+
param :quantity, type: :integer, default: 50
|
|
22
|
+
param :offset_pct, type: :number, default: 1.0
|
|
23
|
+
param :stop_loss, type: :number, default: 200
|
|
24
|
+
param :target, type: :number, default: 400
|
|
25
|
+
|
|
26
|
+
step :find_instrument, priority: 1
|
|
27
|
+
step :get_spot_price, priority: 2
|
|
28
|
+
step :get_option_chain, priority: 3
|
|
29
|
+
step :select_strikes, priority: 4
|
|
30
|
+
step :build_intent, priority: 5
|
|
31
|
+
|
|
32
|
+
def find_instrument(ctx)
|
|
33
|
+
ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
|
|
34
|
+
ctx
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get_spot_price(ctx)
|
|
38
|
+
ltp = ctx[:instrument].ltp
|
|
39
|
+
ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
|
|
40
|
+
ctx
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def get_option_chain(ctx)
|
|
44
|
+
ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
|
|
45
|
+
ctx
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def select_strikes(ctx)
|
|
49
|
+
spot = ctx[:spot_price].to_f
|
|
50
|
+
chain = ctx[:chain]
|
|
51
|
+
offset = ctx[:offset_pct] / 100.0
|
|
52
|
+
|
|
53
|
+
ce_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "CE" }
|
|
54
|
+
pe_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "PE" }
|
|
55
|
+
|
|
56
|
+
ce_strike = spot * (1 + offset)
|
|
57
|
+
pe_strike = spot * (1 - offset)
|
|
58
|
+
|
|
59
|
+
long_ce = ce_options.min_by { |o| ((o[:strike] || o["strike"]).to_f - ce_strike).abs }
|
|
60
|
+
long_pe = pe_options.min_by { |o| ((o[:strike] || o["strike"]).to_f - pe_strike).abs }
|
|
61
|
+
|
|
62
|
+
raise ArgumentError, "Could not find suitable CE strike near #{ce_strike}" unless long_ce
|
|
63
|
+
raise ArgumentError, "Could not find suitable PE strike near #{pe_strike}" unless long_pe
|
|
64
|
+
|
|
65
|
+
ctx[:ce_strike] = long_ce[:strike] || long_ce["strike"]
|
|
66
|
+
ctx[:pe_strike] = long_pe[:strike] || long_pe["strike"]
|
|
67
|
+
ctx[:ce_security_id] = long_ce[:security_id] || long_ce["securityId"]
|
|
68
|
+
ctx[:pe_security_id] = long_pe[:security_id] || long_pe["securityId"]
|
|
69
|
+
ctx[:ce_premium] = long_ce[:last_price] || long_ce["lastPrice"] || long_ce[:ltp]
|
|
70
|
+
ctx[:pe_premium] = long_pe[:last_price] || long_pe["lastPrice"] || long_pe[:ltp]
|
|
71
|
+
ctx
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_intent(ctx)
|
|
75
|
+
ctx[:intent] = {
|
|
76
|
+
trade_type: "STRANGLE",
|
|
77
|
+
symbol: ctx[:symbol],
|
|
78
|
+
expiry: ctx[:expiry],
|
|
79
|
+
quantity: ctx[:quantity],
|
|
80
|
+
legs: [
|
|
81
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: ctx[:ce_strike], security_id: ctx[:ce_security_id], premium: ctx[:ce_premium] },
|
|
82
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: ctx[:pe_strike], security_id: ctx[:pe_security_id], premium: ctx[:pe_premium] }
|
|
83
|
+
],
|
|
84
|
+
stop_loss: ctx[:stop_loss],
|
|
85
|
+
target: ctx[:target],
|
|
86
|
+
note: "Long strangle prepared. Await human confirmation."
|
|
87
|
+
}
|
|
88
|
+
ctx
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
# Registry for skill definitions.
|
|
6
|
+
#
|
|
7
|
+
# Maps skill names to their classes. Skills are registered explicitly
|
|
8
|
+
# or discovered via conventions.
|
|
9
|
+
#
|
|
10
|
+
# @example Register a skill
|
|
11
|
+
# DhanHQ::Skills::Registry.register("buy_atm_call", BuyAtmCall)
|
|
12
|
+
#
|
|
13
|
+
# @example Find a skill
|
|
14
|
+
# skill = DhanHQ::Skills::Registry.find("buy_atm_call")
|
|
15
|
+
# result = skill.call(symbol: "NIFTY", expiry: "2026-01-30")
|
|
16
|
+
#
|
|
17
|
+
class Registry
|
|
18
|
+
class << self
|
|
19
|
+
# Register a skill class by name.
|
|
20
|
+
#
|
|
21
|
+
# @param name [String] skill name
|
|
22
|
+
# @param klass [Class] skill class inheriting from Base
|
|
23
|
+
# @raise [ArgumentError] if the class is not a Base subclass
|
|
24
|
+
def register(name, klass)
|
|
25
|
+
raise ArgumentError, "Skill class must inherit from DhanHQ::Skills::Base" unless klass < Base
|
|
26
|
+
|
|
27
|
+
skills[name.to_s] = klass
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Find a skill by name.
|
|
31
|
+
#
|
|
32
|
+
# @param name [String] skill name
|
|
33
|
+
# @return [Class] the skill class
|
|
34
|
+
# @raise [KeyError] if skill is not found
|
|
35
|
+
def find(name)
|
|
36
|
+
skills.fetch(name.to_s) { raise KeyError, "Unknown skill: #{name}" }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Execute a skill by name with the given arguments.
|
|
40
|
+
#
|
|
41
|
+
# @param name [String] skill name
|
|
42
|
+
# @param args [Hash] skill parameters
|
|
43
|
+
# @return [Hash] context with all accumulated state
|
|
44
|
+
def call(name, args = {})
|
|
45
|
+
find(name).new.call(args)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# List all registered skill names.
|
|
49
|
+
#
|
|
50
|
+
# @return [Array<String>]
|
|
51
|
+
def names
|
|
52
|
+
skills.keys.sort
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# List all registered skills with metadata.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Hash>]
|
|
58
|
+
def list
|
|
59
|
+
skills.map do |name, klass|
|
|
60
|
+
instance = klass.new
|
|
61
|
+
{
|
|
62
|
+
name: name,
|
|
63
|
+
description: instance.description,
|
|
64
|
+
params: instance.param_definitions,
|
|
65
|
+
steps: klass.steps.map { |s| s[:name] }
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Clear all registered skills (for testing).
|
|
71
|
+
def clear!
|
|
72
|
+
@skills = {}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Load built-in skills from the builtin directory and register them.
|
|
76
|
+
def load_builtins
|
|
77
|
+
Dir[File.join(__dir__, "builtin", "*.rb")].each do |file|
|
|
78
|
+
require file
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Auto-register any Builtin classes that inherit from Base
|
|
82
|
+
DhanHQ::Skills::Builtin.constants.each do |const_name|
|
|
83
|
+
klass = DhanHQ::Skills::Builtin.const_get(const_name)
|
|
84
|
+
next unless klass.is_a?(Class) && klass < Base
|
|
85
|
+
|
|
86
|
+
name = const_name.to_s
|
|
87
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
88
|
+
.downcase
|
|
89
|
+
register(name, klass) unless skills.key?(name)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def skills
|
|
96
|
+
@skills ||= {}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
# Workflow orchestrates multi-step skill execution with branching and error handling.
|
|
6
|
+
#
|
|
7
|
+
# A workflow is a sequence of steps that can succeed, fail, or branch
|
|
8
|
+
# based on the result of each step.
|
|
9
|
+
#
|
|
10
|
+
# @example Define a workflow
|
|
11
|
+
# workflow = DhanHQ::Skills::Workflow.new do
|
|
12
|
+
# step :check_funds do |ctx|
|
|
13
|
+
# funds = DhanHQ::Models::Funds.fetch
|
|
14
|
+
# ctx[:available_balance] = funds[:available_balance]
|
|
15
|
+
# ctx
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# step :validate_margin do |ctx|
|
|
19
|
+
# raise "Insufficient margin" if ctx[:available_balance] < 10_000
|
|
20
|
+
# ctx
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# step :place_order do |ctx|
|
|
24
|
+
# ctx[:order] = DhanHQ::Models::Order.place(ctx[:order_params])
|
|
25
|
+
# ctx
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# result = workflow.call(order_params: { ... })
|
|
30
|
+
#
|
|
31
|
+
class Workflow
|
|
32
|
+
Step = Struct.new(:name, :block, :priority)
|
|
33
|
+
|
|
34
|
+
attr_reader :name, :steps
|
|
35
|
+
|
|
36
|
+
def initialize(name: "workflow", &block)
|
|
37
|
+
@name = name
|
|
38
|
+
@steps = []
|
|
39
|
+
instance_eval(&block) if block
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Define a step in the workflow.
|
|
43
|
+
#
|
|
44
|
+
# @param name [Symbol] step name
|
|
45
|
+
# @param priority [Integer] execution order (lower = earlier)
|
|
46
|
+
# @param block [Proc] step implementation
|
|
47
|
+
def step(name, priority: 10, &block)
|
|
48
|
+
@steps << Step.new(name, block, priority)
|
|
49
|
+
@steps.sort_by!(&:priority)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Execute the workflow with the given context.
|
|
53
|
+
#
|
|
54
|
+
# @param ctx [Hash] initial context
|
|
55
|
+
# @return [Hash] final context after all steps
|
|
56
|
+
# @raise [RuntimeError] if any step fails
|
|
57
|
+
def call(ctx = {})
|
|
58
|
+
@steps.each do |step|
|
|
59
|
+
result = step.block.call(ctx)
|
|
60
|
+
ctx = result if result.is_a?(Hash)
|
|
61
|
+
end
|
|
62
|
+
ctx
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Multi-step trading workflows built on top of MCP tools.
|
|
5
|
+
#
|
|
6
|
+
# Skills compose individual tool calls into reusable trading strategies.
|
|
7
|
+
# Each skill defines a sequence of steps that execute in order, with
|
|
8
|
+
# context passed between steps.
|
|
9
|
+
#
|
|
10
|
+
# @example Define a custom skill
|
|
11
|
+
# class MySkill < DhanHQ::Skills::Base
|
|
12
|
+
# param :symbol, type: :string, required: true
|
|
13
|
+
# param :quantity, type: :integer, default: 1
|
|
14
|
+
#
|
|
15
|
+
# step :find_instrument
|
|
16
|
+
# step :place_order
|
|
17
|
+
#
|
|
18
|
+
# def find_instrument(ctx)
|
|
19
|
+
# ctx[:instrument] = DhanHQ::Models::Instrument.find("NSE_EQ", ctx[:symbol])
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# def place_order(ctx)
|
|
23
|
+
# DhanHQ::Models::Order.place(security_id: ctx[:instrument].security_id, quantity: ctx[:quantity])
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
module Skills
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Strategy framework for building and backtesting trading strategies.
|
|
5
|
+
#
|
|
6
|
+
# Provides a DSL for defining entry/exit rules, risk management,
|
|
7
|
+
# and signal generation.
|
|
8
|
+
#
|
|
9
|
+
# @example Define a simple strategy
|
|
10
|
+
# class MyStrategy < DhanHQ::Strategy::Base
|
|
11
|
+
# entry_rule :golden_cross do |data|
|
|
12
|
+
# data.sma(20).last > data.sma(50).last &&
|
|
13
|
+
# data.sma(20).last(2) <= data.sma(50).last(2)
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# exit_rule :death_cross do |data|
|
|
17
|
+
# data.sma(20).last < data.sma(50).last
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
module Strategy
|
|
22
|
+
# Signal represents a trading signal generated by a strategy.
|
|
23
|
+
#
|
|
24
|
+
# @attr_reader [Symbol] type Signal type (:buy, :sell, :hold)
|
|
25
|
+
# @attr_reader [Float] strength Signal strength (0.0 to 1.0)
|
|
26
|
+
# @attr_reader [Hash] metadata Additional signal metadata
|
|
27
|
+
Signal = Struct.new(:type, :strength, :metadata) do
|
|
28
|
+
def buy?
|
|
29
|
+
type == :buy
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sell?
|
|
33
|
+
type == :sell
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def hold?
|
|
37
|
+
type == :hold
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_prompt
|
|
41
|
+
parts = ["signal=#{type}", "strength=#{strength}"]
|
|
42
|
+
parts << "reason=#{metadata[:reason]}" if metadata[:reason]
|
|
43
|
+
parts.join(", ")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Base class for all strategies.
|
|
48
|
+
#
|
|
49
|
+
# Provides DSL for defining entry/exit rules and risk management.
|
|
50
|
+
class Base
|
|
51
|
+
attr_reader :name, :params, :position
|
|
52
|
+
|
|
53
|
+
def initialize(name: self.class.name, params: {})
|
|
54
|
+
@name = name
|
|
55
|
+
@params = params
|
|
56
|
+
@position = nil
|
|
57
|
+
@entry_rules = {}
|
|
58
|
+
@exit_rules = {}
|
|
59
|
+
@risk_rules = {}
|
|
60
|
+
@signals = []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Define an entry rule.
|
|
64
|
+
#
|
|
65
|
+
# @param name [Symbol] Rule name
|
|
66
|
+
# @param priority [Integer] Rule priority (lower = higher priority)
|
|
67
|
+
# @param block [Proc] Rule condition block
|
|
68
|
+
def self.entry_rule(name, priority: 10, &block)
|
|
69
|
+
@entry_rules ||= {}
|
|
70
|
+
@entry_rules[name] = { priority: priority, block: block }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Define an exit rule.
|
|
74
|
+
#
|
|
75
|
+
# @param name [Symbol] Rule name
|
|
76
|
+
# @param priority [Integer] Rule priority (lower = higher priority)
|
|
77
|
+
# @param block [Proc] Rule condition block
|
|
78
|
+
def self.exit_rule(name, priority: 10, &block)
|
|
79
|
+
@exit_rules ||= {}
|
|
80
|
+
@exit_rules[name] = { priority: priority, block: block }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Define a risk rule.
|
|
84
|
+
#
|
|
85
|
+
# @param name [Symbol] Rule name
|
|
86
|
+
# @param block [Proc] Risk check block
|
|
87
|
+
def self.risk_rule(name, &block)
|
|
88
|
+
@risk_rules ||= {}
|
|
89
|
+
@risk_rules[name] = block
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get all entry rules for this strategy class.
|
|
93
|
+
def self.entry_rules
|
|
94
|
+
@entry_rules || {}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Get all exit rules for this strategy class.
|
|
98
|
+
def self.exit_rules
|
|
99
|
+
@exit_rules || {}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get all risk rules for this strategy class.
|
|
103
|
+
def self.risk_rules
|
|
104
|
+
@risk_rules || {}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Evaluate entry rules against market data.
|
|
108
|
+
#
|
|
109
|
+
# @param data [DhanHQ::MarketData::OHLCSeries] Market data series
|
|
110
|
+
# @return [DhanHQ::Strategy::Signal] Generated signal
|
|
111
|
+
def evaluate_entry(data)
|
|
112
|
+
sorted_rules = self.class.entry_rules.sort_by { |_, v| v[:priority] }
|
|
113
|
+
|
|
114
|
+
sorted_rules.each do |rule_name, rule|
|
|
115
|
+
result = rule[:block].call(data, params)
|
|
116
|
+
next unless result
|
|
117
|
+
|
|
118
|
+
signal = Signal.new(
|
|
119
|
+
type: :buy,
|
|
120
|
+
strength: calculate_strength(result),
|
|
121
|
+
metadata: { rule: rule_name, data: data }
|
|
122
|
+
)
|
|
123
|
+
@signals << signal
|
|
124
|
+
return signal
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
Signal.new(type: :hold, strength: 0.0, metadata: { reason: :no_entry_signal })
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Evaluate exit rules against market data.
|
|
131
|
+
#
|
|
132
|
+
# @param data [DhanHQ::MarketData::OHLCSeries] Market data series
|
|
133
|
+
# @return [DhanHQ::Strategy::Signal] Generated signal
|
|
134
|
+
def evaluate_exit(data)
|
|
135
|
+
sorted_rules = self.class.exit_rules.sort_by { |_, v| v[:priority] }
|
|
136
|
+
|
|
137
|
+
sorted_rules.each do |rule_name, rule|
|
|
138
|
+
result = rule[:block].call(data, params)
|
|
139
|
+
next unless result
|
|
140
|
+
|
|
141
|
+
signal = Signal.new(
|
|
142
|
+
type: :sell,
|
|
143
|
+
strength: calculate_strength(result),
|
|
144
|
+
metadata: { rule: rule_name, data: data }
|
|
145
|
+
)
|
|
146
|
+
@signals << signal
|
|
147
|
+
return signal
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
Signal.new(type: :hold, strength: 0.0, metadata: { reason: :no_exit_signal })
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Check all risk rules.
|
|
154
|
+
#
|
|
155
|
+
# @param context [Hash] Risk check context
|
|
156
|
+
# @return [Array<Symbol>] List of violated rule names
|
|
157
|
+
def check_risks(context)
|
|
158
|
+
violations = []
|
|
159
|
+
|
|
160
|
+
self.class.risk_rules.each do |rule_name, block|
|
|
161
|
+
result = block.call(context, params)
|
|
162
|
+
violations << rule_name unless result
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
violations
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Get all signals generated by this strategy.
|
|
169
|
+
def signals
|
|
170
|
+
@signals.dup
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Get the last signal.
|
|
174
|
+
def last_signal
|
|
175
|
+
@signals.last
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def calculate_strength(result)
|
|
181
|
+
case result
|
|
182
|
+
when true then 1.0
|
|
183
|
+
when Numeric then result.clamp(0.0, 1.0)
|
|
184
|
+
else 0.5
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "strategy/base"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
# Strategy framework for building and backtesting trading strategies.
|
|
7
|
+
#
|
|
8
|
+
# Provides a DSL for defining entry/exit rules, risk management,
|
|
9
|
+
# and signal generation.
|
|
10
|
+
#
|
|
11
|
+
# @example Define a simple strategy
|
|
12
|
+
# class GoldenCross < DhanHQ::Strategy::Base
|
|
13
|
+
# entry_rule :golden_cross do |data, _params|
|
|
14
|
+
# sma_20 = DhanHQ::Indicators::SMA.calculate(data.closes, period: 20)
|
|
15
|
+
# sma_50 = DhanHQ::Indicators::SMA.calculate(data.closes, period: 50)
|
|
16
|
+
#
|
|
17
|
+
# sma_20.last && sma_50.last &&
|
|
18
|
+
# sma_20.last > sma_50.last &&
|
|
19
|
+
# sma_20[-2] && sma_50[-2] &&
|
|
20
|
+
# sma_20[-2] <= sma_50[-2]
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# exit_rule :death_cross do |data, _params|
|
|
24
|
+
# sma_20 = DhanHQ::Indicators::SMA.calculate(data.closes, period: 20)
|
|
25
|
+
# sma_50 = DhanHQ::Indicators::SMA.calculate(data.closes, period: 50)
|
|
26
|
+
#
|
|
27
|
+
# sma_20.last && sma_50.last && sma_20.last < sma_50.last
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# risk_rule :max_drawdown do |context, _params|
|
|
31
|
+
# context[:drawdown].abs < 0.1
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# strategy = GoldenCross.new
|
|
36
|
+
# signal = strategy.evaluate_entry(data)
|
|
37
|
+
#
|
|
38
|
+
module Strategy
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/DhanHQ/version.rb
CHANGED
data/lib/DhanHQ/ws/decoder.rb
CHANGED
|
@@ -26,21 +26,45 @@ module DhanHQ
|
|
|
26
26
|
segstr = Segments.from_code(pkt[:exchange_segment])
|
|
27
27
|
sid = pkt[:security_id].to_s
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
dispatch(pkt, kind, segstr, sid)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
DhanHQ.logger&.debug("[DhanHQ::WS::Decoder] #{e.class}: #{e.message}")
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def dispatch(pkt, kind, segstr, sid)
|
|
39
|
+
case kind
|
|
40
|
+
when :ticker then decode_ticker(pkt, segstr, sid)
|
|
41
|
+
when :quote then decode_quote(pkt, segstr, sid)
|
|
42
|
+
when :full then decode_full(pkt, segstr, sid)
|
|
43
|
+
when :oi then decode_oi(pkt, segstr, sid)
|
|
44
|
+
when :prev_close then decode_prev_close(pkt, segstr, sid)
|
|
45
|
+
when :depth_bid, :depth_ask then decode_depth(pkt, kind, segstr, sid)
|
|
46
|
+
when :disconnect then handle_disconnect(pkt, segstr, sid)
|
|
47
|
+
else handle_unknown(pkt)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def decode_ticker(pkt, segstr, sid)
|
|
32
52
|
{
|
|
33
53
|
kind: :ticker, segment: segstr, security_id: sid,
|
|
34
54
|
ltp: pkt[:ltp].to_f, ts: pkt[:ltt]&.to_i
|
|
35
55
|
}
|
|
36
|
-
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def decode_quote(pkt, segstr, sid)
|
|
37
59
|
{
|
|
38
60
|
kind: :quote, segment: segstr, security_id: sid,
|
|
39
61
|
ltp: pkt[:ltp].to_f, ts: pkt[:ltt]&.to_i, atp: pkt[:atp].to_f,
|
|
40
62
|
vol: pkt[:volume].to_i, ts_buy_qty: pkt[:total_buy_qty].to_i, ts_sell_qty: pkt[:total_sell_qty].to_i,
|
|
41
63
|
day_open: pkt[:day_open]&.to_f, day_high: pkt[:day_high]&.to_f, day_low: pkt[:day_low]&.to_f, day_close: pkt[:day_close]&.to_f
|
|
42
64
|
}
|
|
43
|
-
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def decode_full(pkt, segstr, sid)
|
|
44
68
|
out = {
|
|
45
69
|
kind: :full, segment: segstr, security_id: sid,
|
|
46
70
|
ltp: pkt[:ltp].to_f, ts: pkt[:ltt]&.to_i, atp: pkt[:atp].to_f,
|
|
@@ -48,35 +72,49 @@ module DhanHQ
|
|
|
48
72
|
oi: pkt[:open_interest]&.to_i, oi_high: pkt[:highest_open_interest]&.to_i, oi_low: pkt[:lowest_open_interest]&.to_i,
|
|
49
73
|
day_open: pkt[:day_open]&.to_f, day_high: pkt[:day_high]&.to_f, day_low: pkt[:day_low]&.to_f, day_close: pkt[:day_close]&.to_f
|
|
50
74
|
}
|
|
51
|
-
|
|
52
|
-
if (md = pkt[:market_depth]).respond_to?(:[]) && md[0]
|
|
53
|
-
lvl = md[0]
|
|
54
|
-
out[:bid] = lvl.respond_to?(:bid_price) ? lvl.bid_price.to_f : nil
|
|
55
|
-
out[:ask] = lvl.respond_to?(:ask_price) ? lvl.ask_price.to_f : nil
|
|
56
|
-
end
|
|
75
|
+
merge_depth(out, pkt[:market_depth])
|
|
57
76
|
out
|
|
58
|
-
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def decode_oi(pkt, segstr, sid)
|
|
59
80
|
{ kind: :oi, segment: segstr, security_id: sid, oi: pkt[:open_interest].to_i }
|
|
60
|
-
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def decode_prev_close(pkt, segstr, sid)
|
|
61
84
|
{ kind: :prev_close, segment: segstr, security_id: sid, prev_close: pkt[:prev_close].to_f,
|
|
62
85
|
oi_prev: pkt[:oi_prev].to_i }
|
|
63
|
-
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def decode_depth(pkt, kind, segstr, sid)
|
|
64
89
|
{
|
|
65
90
|
kind: kind, segment: segstr, security_id: sid,
|
|
66
91
|
bid_quantity: pkt[:bid_quantity], ask_quantity: pkt[:ask_quantity],
|
|
67
92
|
no_of_bid_orders: pkt[:no_of_bid_orders], no_of_ask_orders: pkt[:no_of_ask_orders],
|
|
68
93
|
bid: pkt[:bid_price], ask: pkt[:ask_price]
|
|
69
94
|
}
|
|
70
|
-
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_disconnect(pkt, segstr, sid)
|
|
71
98
|
DhanHQ.logger&.warn("[DhanHQ::WS] disconnect code=#{pkt[:disconnection_code]} seg=#{segstr} sid=#{sid}")
|
|
72
99
|
nil
|
|
73
|
-
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handle_unknown(pkt)
|
|
74
103
|
DhanHQ.logger&.debug("[DhanHQ::WS] unknown feed kind code=#{pkt[:feed_response_code]}")
|
|
75
104
|
nil
|
|
76
105
|
end
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
106
|
+
|
|
107
|
+
def merge_depth(out, market_depth)
|
|
108
|
+
return unless market_depth.respond_to?(:[]) && market_depth[0]
|
|
109
|
+
|
|
110
|
+
lvl = market_depth[0]
|
|
111
|
+
out[:bid] = lvl.respond_to?(:bid_price) ? lvl.bid_price.to_f : nil
|
|
112
|
+
out[:ask] = lvl.respond_to?(:ask_price) ? lvl.ask_price.to_f : nil
|
|
113
|
+
return unless lvl.respond_to?(:bid_quantity) && lvl.respond_to?(:ask_quantity)
|
|
114
|
+
|
|
115
|
+
out[:bid_qty] = lvl.bid_quantity.to_i
|
|
116
|
+
out[:ask_qty] = lvl.ask_quantity.to_i
|
|
117
|
+
end
|
|
80
118
|
end
|
|
81
119
|
end
|
|
82
120
|
end
|
data/lib/DhanHQ.rb
ADDED